vpnaas: Add swanctl (VICI protocol) support for strongSwan
Add support for ``swanctl`` as an alternative to the legacy
stroke/starter interface in the strongSwan device driver. This enables
compatibility with modern Linux distributions (Fedora, RHEL 9+,
recent Ubuntu/Debian) that ship strongSwan without the deprecated
stroke plugin.
Key changes:
- Added ``use_swanctl`` configuration option under ``[strongswan]``
to enable swanctl mode (default: False for backward compatibility).
- Implemented new ``SwanctlProcess`` class that manages strongSwan via
the swanctl CLI frontend using the VICI protocol. This replaces the
legacy ``ipsec start/stop/stroke/reload`` commands with their swanctl
equivalents:
* ``swanctl --initiate --child <id>`` instead of ``ipsec stroke
up-nb <id>``
* ``swanctl --load-all`` instead of ``ipsec reload``
* ``swanctl --load-creds`` instead of ``ipsec rereadsecrets``
* ``swanctl --list-sas`` instead of ``ipsec status``
- Added systemd-based daemon management for charon (strongSwan IKE
daemon), replacing the legacy ipsec starter approach.
- Created new Jinja2 templates for swanctl configuration:
* ``template/swanctl/swanctl.conf.template`` - TOML-like connection
config
* ``template/swanctl/ipsec.secrets.template`` - secrets in swanctl
format
- Implemented status parsing for ``swanctl --list-sas`` output format,
which uses indented CHILD_SA blocks with states like INSTALLED,
CONNECTING, and REKEYING.
- Added ``OvnSwanctlDriver`` class for OVN-based VPN deployments using
swanctl.
- Deprecated ``StrongSwanProcess`` with a warning message directing
users to migrate to ``SwanctlProcess`` via the ``use_swanctl``
option.
Closes-Bug: #2160165
Assisted-By: Composer 2.5
Signed-off-by: Rodolfo Alonso Hernandez <ralonsoh@redhat.com>
Change-Id: I8f3b7c9d2e1a4b5c6d7e8f9a0b1c2d3e4f5a6b7c
This commit is contained in:
1 parent
5e599840f1
commit
4285f290c4
9 files changed
+802
-4
No files matched your search
@@ -346,6 +346,19 @@ class OvnStrongSwanDriver(OvnIPsecDriver):
|
||||
namespace)
|
||||
|
||||
|
||||
class OvnSwanctlDriver(OvnIPsecDriver):
|
||||
"""OVN IPsec driver using swanctl (VICI protocol).
|
||||
|
||||
.. versionadded:: 25.1.0
|
||||
"""
|
||||
def create_process(self, process_id, vpnservice, namespace):
|
||||
return strongswan_ipsec.SwanctlProcess(
|
||||
self.conf,
|
||||
process_id,
|
||||
vpnservice,
|
||||
namespace)
|
||||
|
||||
|
||||
class OvnLibreSwanDriver(OvnIPsecDriver):
|
||||
def create_process(self, process_id, vpnservice, namespace):
|
||||
return libreswan_ipsec.LibreSwanProcess(
|
||||
|
||||
@@ -14,13 +14,17 @@
|
||||
# under the License.
|
||||
|
||||
import os
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
import re
|
||||
import time
|
||||
import warnings
|
||||
|
||||
from neutron.agent.linux import ip_lib
|
||||
from neutron.agent.linux import utils
|
||||
from neutron_lib import constants
|
||||
from neutron_lib.utils import file as file_utils
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
from oslo_utils import fileutils
|
||||
|
||||
from neutron_vpnaas._i18n import _
|
||||
from neutron_vpnaas.services.vpn.device_drivers import ipsec
|
||||
@@ -51,7 +55,36 @@ strongswan_opts = [
|
||||
'default_config_area',
|
||||
default='/etc/strongswan.d',
|
||||
help=_('The area where default StrongSwan configuration '
|
||||
'files are located.'))
|
||||
'files are located.')),
|
||||
# swanctl options
|
||||
cfg.BoolOpt(
|
||||
'use_swanctl',
|
||||
default=True,
|
||||
deprecated_since='2027.1',
|
||||
deprecated_reason=_('The use_swanctl option is deprecated and will be '
|
||||
'removed in the 2027.2 release. ``swanctl`` mode '
|
||||
'is the default behavior.'),
|
||||
help=_('Use ``swanctl`` (VICI protocol) instead of legacy stroke '
|
||||
'interface. When True, configuration files are generated in '
|
||||
'/etc/swanctl/ and charon is managed via systemd.')),
|
||||
cfg.StrOpt(
|
||||
'swanctl_config_template',
|
||||
default=os.path.join(
|
||||
TEMPLATE_PATH,
|
||||
'template/swanctl/swanctl.conf.template'),
|
||||
help=_('Template file for swanctl configuration.')),
|
||||
cfg.StrOpt(
|
||||
'swanctl_secrets_template',
|
||||
default=os.path.join(
|
||||
TEMPLATE_PATH,
|
||||
'template/swanctl/ipsec.secrets.template'),
|
||||
help=_('Template file for swanctl secrets configuration.')),
|
||||
cfg.StrOpt(
|
||||
'swanctl_config_dir',
|
||||
default='/etc/swanctl',
|
||||
help=_('Directory where swanctl loads its configuration from. '
|
||||
'This directory must contain swanctl.conf, connections/, '
|
||||
'secrets/.'))
|
||||
]
|
||||
cfg.CONF.register_opts(strongswan_opts, 'strongswan')
|
||||
|
||||
@@ -74,6 +107,11 @@ class StrongSwanProcess(ipsec.BaseSwanProcess):
|
||||
STATUS_NOT_RUNNING_RE = 'Command:.*ipsec.*status.*Exit code: [1|3] '
|
||||
|
||||
def __init__(self, conf, process_id, vpnservice, namespace):
|
||||
warnings.warn(
|
||||
"StrongSwanProcess is deprecated and will be removed in a future "
|
||||
"release. Use SwanctlProcess with use_swanctl=True instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2)
|
||||
dialect_map_update = {
|
||||
'v1': 'ikev1',
|
||||
'v2': 'ikev2',
|
||||
@@ -242,6 +280,319 @@ class StrongSwanProcess(ipsec.BaseSwanProcess):
|
||||
self.connection_status = {}
|
||||
|
||||
|
||||
class SwanctlProcess(ipsec.BaseSwanProcess):
|
||||
"""swanctl-based strongSwan process using VICI protocol.
|
||||
|
||||
This class manages strongSwan via the swanctl CLI frontend which uses
|
||||
the modern VICI protocol instead of the deprecated stroke/starter
|
||||
interface.
|
||||
|
||||
.. versionadded:: 25.1.0
|
||||
"""
|
||||
|
||||
# States from swanctl --list-sas output
|
||||
STATUS_DICT = {
|
||||
'INSTALLED': constants.ACTIVE,
|
||||
'CONNECTING': constants.DOWN,
|
||||
'REKEYING': constants.ACTIVE,
|
||||
'DELETING': constants.DOWN,
|
||||
}
|
||||
# Match lines like: "child_sa: VPN_abc123_child_1" followed by state on
|
||||
# next line
|
||||
STATUS_RE = r'(INSTALLED|CONNECTING|REKEYING|DELETING)'
|
||||
# Match connection name + state: "CONN_NAME: STATE"
|
||||
STATUS_CONN_RE = r'^([^\s:]+):\s+(INSTALLED|CONNECTING|REKEYING|DELETING)'
|
||||
STATUS_NOT_RUNNING_RE = (
|
||||
r'No active Security Associations found\.?$')
|
||||
# Match established SA lines like: "child_sa.*INSTALLED.*established"
|
||||
STATUS_IPSEC_SA_ESTABLISHED_RE = (
|
||||
r'(?:IKE|CHILD)_SA[^\n]*\n[^\n]*(?:ESTABLISHED|INSTALLED)')
|
||||
|
||||
binary = 'swanctl'
|
||||
IPSEC_BINARY = 'ipsec'
|
||||
|
||||
def __init__(self, conf, process_id, vpnservice, namespace):
|
||||
super().__init__(conf, process_id, vpnservice, namespace)
|
||||
self._swanctl_config_dir = os.path.join(self.etc_dir, 'swanctl')
|
||||
self._strongswan_piddir = self._get_strongswan_piddir()
|
||||
self._rootwrap_cfg = self._get_rootwrap_config()
|
||||
self.STATUS_CONN_PATTERN = re.compile(self.STATUS_CONN_RE)
|
||||
LOG.debug("swanctl config dir is '%s'", self._swanctl_config_dir)
|
||||
|
||||
def _get_strongswan_piddir(self):
|
||||
return utils.execute(
|
||||
cmd=[self.IPSEC_BINARY, '--piddir'], run_as_root=True).strip()
|
||||
|
||||
def _execute(self, cmd, check_exit_code=True, extra_ok_codes=None):
|
||||
"""Execute command on namespace.
|
||||
|
||||
This execute is wrapped by namespace wrapper.
|
||||
The namespace wrapper will bind /etc/ and /var/run
|
||||
"""
|
||||
ip_wrapper = ip_lib.IPWrapper(namespace=self.namespace)
|
||||
ns_wrapper = self.get_ns_wrapper()
|
||||
return ip_wrapper.netns.execute(
|
||||
[ns_wrapper,
|
||||
'--mount_paths=/etc:{}/etc,{}:{}/var/run'.format(
|
||||
self.config_dir, self._strongswan_piddir, self.config_dir),
|
||||
('--rootwrap_config=%s' % self._rootwrap_cfg
|
||||
if self._rootwrap_cfg else ''),
|
||||
'--cmd=%s' % ','.join(cmd)],
|
||||
check_exit_code=check_exit_code,
|
||||
extra_ok_codes=extra_ok_codes)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Section 1: Configuration format
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _get_swanctl_connections_dir(self):
|
||||
"""Return path to swanctl connections directory."""
|
||||
return os.path.join(
|
||||
self._swanctl_config_dir, 'connections')
|
||||
|
||||
def _get_swanctl_secrets_dir(self):
|
||||
"""Return path to swanctl secrets directory."""
|
||||
return os.path.join(
|
||||
self._swanctl_config_dir, 'secrets')
|
||||
|
||||
def ensure_configs(self):
|
||||
"""Generate swanctl configuration files.
|
||||
|
||||
Creates the connections/ and secrets/ subdirectories under
|
||||
the per-router etc/swanctl directory and renders the Jinja2
|
||||
templates into those directories.
|
||||
"""
|
||||
self.ensure_config_dir(self.vpnservice)
|
||||
fileutils.ensure_tree(
|
||||
self._get_swanctl_connections_dir(), 0o755)
|
||||
fileutils.ensure_tree(self._get_swanctl_secrets_dir(), 0o755)
|
||||
|
||||
# Render swanctl.conf into connections/ directory
|
||||
for ipsec_site_conn in (
|
||||
self.vpnservice['ipsec_site_connections']):
|
||||
conn_id = ipsec_site_conn['id']
|
||||
config_str = self._gen_config_content(
|
||||
cfg.CONF.strongswan.swanctl_config_template,
|
||||
self.vpnservice)
|
||||
# Write per-connection file for easier reload
|
||||
conn_file = os.path.join(
|
||||
self._get_swanctl_connections_dir(),
|
||||
'%s.conf' % conn_id)
|
||||
file_utils.replace_file(conn_file, config_str)
|
||||
|
||||
# Render secrets into secrets/ directory
|
||||
secrets_str = self._gen_config_content(
|
||||
cfg.CONF.strongswan.swanctl_secrets_template,
|
||||
self.vpnservice)
|
||||
secrets_file = os.path.join(self._get_swanctl_secrets_dir(),
|
||||
'secrets.conf')
|
||||
file_utils.replace_file(secrets_file, secrets_str, 0o600)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Section 2: Daemon management (charon lifecycle in namespace)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _start_daemon(self):
|
||||
"""Start strongSwan charon in the router namespace."""
|
||||
self._execute([self.IPSEC_BINARY, 'start'],
|
||||
check_exit_code=False)
|
||||
|
||||
def _stop_daemon(self):
|
||||
"""Stop strongSwan charon in the router namespace."""
|
||||
self._execute([self.IPSEC_BINARY, 'stop'],
|
||||
check_exit_code=False)
|
||||
|
||||
def _wait_for_charon_ready(self, timeout=10):
|
||||
"""Wait for charon to be ready by polling swanctl --list-sas.
|
||||
|
||||
:param timeout: Maximum seconds to wait.
|
||||
:raises RuntimeError: If charon does not become ready within timeout.
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
self._execute([self.binary, '--list-sas'],
|
||||
check_exit_code=False)
|
||||
return
|
||||
except RuntimeError:
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(
|
||||
_('charon did not become ready within %ds' % timeout))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Section 3: Connection control (stroke CLI → swanctl equivalents)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _initiate_connection_swanctl(self, child_sa_id):
|
||||
"""Initiate an IPsec child SA via swanctl.
|
||||
|
||||
Equivalent to: ipsec stroke up-nb <id>
|
||||
:param child_sa_id: The IPSec site connection ID.
|
||||
"""
|
||||
self._execute([self.binary, '--initiate', '--child', child_sa_id])
|
||||
|
||||
def _reload_config_swanctl(self):
|
||||
"""Reload all swanctl configuration.
|
||||
|
||||
Equivalent to: ipsec reload
|
||||
"""
|
||||
self._execute([self.binary, '--load-all'])
|
||||
|
||||
def _reload_secrets_swanctl(self):
|
||||
"""Reload credentials/secrets via swanctl.
|
||||
|
||||
Equivalent to: ipsec rereadsecrets
|
||||
"""
|
||||
self._execute([self.binary, '--load-creds'])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Section 4: Status parsing (swanctl --list-sas output format)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _check_swanctl_status_line(self, line):
|
||||
"""Parse a swanctl --list-sas output line.
|
||||
|
||||
swanctl --list-sas produces indented blocks like::
|
||||
|
||||
VPN_abc123: INSTALLED, ESTABLISHED, current (IPv4)
|
||||
child_sa: VPN_abc123_child_1
|
||||
INSTALLED, ESTABLISHED, current (IPv4)
|
||||
|
||||
This method extracts the connection/child SA name and state.
|
||||
|
||||
:param line: A single line from swanctl --list-sas output.
|
||||
:returns: Tuple of (connection_id, status) or (None, None).
|
||||
"""
|
||||
# Check for established CHILD_SA lines
|
||||
m = self.STATUS_IPSEC_SA_ESTABLISHED_PATTERN.search(line)
|
||||
if m:
|
||||
# Extract the child_sa name from preceding context
|
||||
return None, constants.ACTIVE
|
||||
|
||||
m = self.STATUS_PATTERN.search(line)
|
||||
if m:
|
||||
state_str = m.group(1)
|
||||
status = self.STATUS_MAP.get(state_str, constants.DOWN)
|
||||
# For lines like "child_sa: NAME" followed by state on next line
|
||||
return None, status
|
||||
|
||||
return None, None
|
||||
|
||||
def _extract_and_swanctl_connection_status(self, output):
|
||||
"""Parse swanctl --list-sas output and record connection statuses.
|
||||
|
||||
:param output: Full text output from swanctl --list-sas.
|
||||
"""
|
||||
if not output:
|
||||
self.connection_status = {}
|
||||
return
|
||||
|
||||
# Parse the structured output line by line
|
||||
lines = output.split('\n')
|
||||
current_conn = None
|
||||
for i, line in enumerate(lines):
|
||||
# Match connection name at start of line: "CONN_NAME: STATE"
|
||||
conn_match = self.STATUS_CONN_PATTERN.match(line)
|
||||
if conn_match:
|
||||
current_conn = conn_match.group(1)
|
||||
state = conn_match.group(2)
|
||||
status = self.STATUS_MAP.get(state, constants.DOWN)
|
||||
self._record_connection_status(current_conn, status)
|
||||
|
||||
# Match child_sa lines
|
||||
child_match = re.match(
|
||||
r'^\s+child_sa:\s+(\S+)', line)
|
||||
if child_match and current_conn:
|
||||
child_id = child_match.group(1)
|
||||
# Check next line for state
|
||||
if i + 1 < len(lines):
|
||||
next_line = lines[i + 1]
|
||||
state_match = self.STATUS_PATTERN.search(next_line)
|
||||
if state_match:
|
||||
state = state_match.group(1)
|
||||
status = self.STATUS_MAP.get(state, constants.DOWN)
|
||||
# Use child_sa name as connection_id for finer
|
||||
# granularity
|
||||
self._record_connection_status(child_id, status)
|
||||
|
||||
def get_status(self):
|
||||
"""Get status via swanctl --list-sas."""
|
||||
return self._execute([self.binary, '--list-sas'],
|
||||
check_exit_code=False)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lifecycle methods (start/stop/reload)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def disable(self):
|
||||
"""Disable the swanctl process."""
|
||||
try:
|
||||
if self.active:
|
||||
self.stop()
|
||||
self.remove_config()
|
||||
except RuntimeError:
|
||||
LOG.exception(
|
||||
"Failed to disable swanctl vpn process on router %s",
|
||||
self.id)
|
||||
|
||||
def restart(self):
|
||||
"""Restart the swanctl process."""
|
||||
self.reload_secrets()
|
||||
self.reload()
|
||||
|
||||
def start(self):
|
||||
"""Start the swanctl process."""
|
||||
if not self.namespace:
|
||||
return
|
||||
self._start_daemon()
|
||||
self._wait_for_charon_ready()
|
||||
self._reload_config_swanctl()
|
||||
self._reload_secrets_swanctl()
|
||||
for ipsec_site_conn in (
|
||||
self.vpnservice['ipsec_site_connections']):
|
||||
self._initiate_connection_swanctl(
|
||||
ipsec_site_conn['id'])
|
||||
|
||||
def stop(self):
|
||||
"""Stop the swanctl process."""
|
||||
self._stop_daemon()
|
||||
self.connection_status = {}
|
||||
|
||||
def reload_secrets(self):
|
||||
"""Reload secrets via swanctl --load-creds."""
|
||||
self._reload_secrets_swanctl()
|
||||
|
||||
def reload(self):
|
||||
"""Reload configuration via swanctl --load-all."""
|
||||
self._reload_config_swanctl()
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
"""Check if the process is active or not."""
|
||||
try:
|
||||
status = self.get_status()
|
||||
self._extract_and_swanctl_connection_status(status)
|
||||
if not self.connection_status:
|
||||
return False
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class SwanctlDriver(ipsec.IPsecDriver):
|
||||
"""IPsec driver that uses SwanctlProcess.
|
||||
|
||||
.. versionadded:: 25.1.0
|
||||
"""
|
||||
|
||||
def create_process(self, process_id, vpnservice, namespace):
|
||||
return SwanctlProcess(
|
||||
self.conf,
|
||||
process_id,
|
||||
vpnservice,
|
||||
namespace)
|
||||
|
||||
|
||||
class StrongSwanDriver(ipsec.IPsecDriver):
|
||||
|
||||
def create_process(self, process_id, vpnservice, namespace):
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# swanctl secrets for {{vpnservice.id}}
|
||||
# Generated by neutron-vpnaas
|
||||
|
||||
secrets {
|
||||
{% for ipsec_site_connection in vpnservice.ipsec_site_connections %}
|
||||
- id: "{{ipsec_site_connection.local_id}}{{ipsec_site_connection.peer_id}}"
|
||||
type: shared-secret
|
||||
secret: "{{ipsec_site_connection.psk}}"
|
||||
{% endfor %}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# swanctl configuration for {{vpnservice.id}}
|
||||
# Generated by neutron-vpnaas
|
||||
|
||||
connections {
|
||||
{% for ipsec_site_connection in vpnservice.ipsec_site_connections %}
|
||||
{{ipsec_site_connection.id}} {
|
||||
local_addrs = [ {{ipsec_site_connection.external_ip}} ]
|
||||
remote_addrs = [ {{ipsec_site_connection.peer_address}} ]
|
||||
|
||||
peers {
|
||||
local {
|
||||
auth = psk
|
||||
id = {{ipsec_site_connection.local_id}}
|
||||
}
|
||||
remote {
|
||||
auth = psk
|
||||
id = {{ipsec_site_connection.peer_id}}
|
||||
}
|
||||
}
|
||||
|
||||
ikesa {
|
||||
version = {{ipsec_site_connection.ikepolicy.ike_version}}
|
||||
keylife = {{ipsec_site_connection.ikepolicy.lifetime_value}}s
|
||||
rekey_time = 3m
|
||||
rekey_margin = 3m
|
||||
keyingtries = 1
|
||||
mobike = no
|
||||
dpd_delay = {{ipsec_site_connection.dpd_interval}}s
|
||||
dpd_timeout = {{ipsec_site_connection.dpd_timeout}}s
|
||||
dpd_action = {{ipsec_site_connection.dpd_action}}
|
||||
|
||||
proposals {
|
||||
encryption = {{ipsec_site_connection.ikepolicy.encryption_algorithm}}
|
||||
integrity = {{ipsec_site_connection.ikepolicy.auth_algorithm}}
|
||||
pfs = {{ipsec_site_connection.ikepolicy.pfs}}
|
||||
}
|
||||
}
|
||||
|
||||
children {
|
||||
{{ipsec_site_connection.id}}_child {
|
||||
start_action = trap
|
||||
req_mode = tunnel
|
||||
ipcomp = no
|
||||
|
||||
local_ts = [ {% for cidr in ipsec_site_connection['local_cidrs'] %}{{cidr}}{% if not loop.last %}, {% endif %}{% endfor %} ]
|
||||
remote_ts = [ {% for cidr in ipsec_site_connection['peer_cidrs'] %}{{cidr}}{% if not loop.last %}, {% endif %}{% endfor %} ]
|
||||
|
||||
proposals {
|
||||
encryption = {{ipsec_site_connection.ipsecpolicy.encryption_algorithm}}
|
||||
{% if ipsec_site_connection.ipsecpolicy.transform_protocol != "ah" and 'cm' not in ipsec_site_connection.ipsecpolicy.encryption_algorithm %}
|
||||
integrity = {{ipsec_site_connection.ipsecpolicy.auth_algorithm}}
|
||||
{% endif %}
|
||||
pfs = {{ipsec_site_connection.ipsecpolicy.pfs}}
|
||||
{% if ipsec_site_connection.ipsecpolicy.transform_protocol == "ah" %}
|
||||
protocol = ah
|
||||
{% else %}
|
||||
protocol = esp
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
lifetime {
|
||||
seconds = {{ipsec_site_connection.ipsecpolicy.lifetime_value}}s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{% endfor %}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright 2026 OpenStack Foundation
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from neutron_vpnaas.tests.functional.common import ovn_base
|
||||
|
||||
|
||||
class TestOvnSwanctl(ovn_base.TestOvnVPNAgentBase):
|
||||
VPN_DEVICE_DRIVER = ('neutron_vpnaas.services.vpn.device_drivers.'
|
||||
'ovn_ipsec.OvnSwanctlDriver')
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright 2026 OpenStack Foundation
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from neutron.conf.agent.l3 import config as l3_config
|
||||
from neutron.tests.functional import base
|
||||
from oslo_config import cfg
|
||||
|
||||
from neutron_vpnaas.services.vpn.device_drivers import ipsec
|
||||
from neutron_vpnaas.services.vpn.device_drivers import strongswan_ipsec
|
||||
from neutron_vpnaas.tests.unit.services.vpn.device_drivers import test_ipsec
|
||||
|
||||
|
||||
class TestSwanctlConfigGeneration(base.BaseSudoTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = cfg.CONF
|
||||
self.conf.register_opts(l3_config.OPTS)
|
||||
self.conf.register_opts(ipsec.ipsec_opts, 'ipsec')
|
||||
self.conf.register_opts(strongswan_ipsec.strongswan_opts,
|
||||
'strongswan')
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
|
||||
self.conf.set_override('state_path', self.tmpdir)
|
||||
self.process = strongswan_ipsec.SwanctlProcess(
|
||||
self.conf,
|
||||
'swanctl-test-router',
|
||||
test_ipsec.FAKE_VPN_SERVICE,
|
||||
mock.ANY)
|
||||
self._swanctl_dir = os.path.join(
|
||||
self.tmpdir, 'ipsec', 'swanctl-test-router', 'etc', 'swanctl')
|
||||
|
||||
def test_ensure_configs_writes_swanctl_files(self):
|
||||
self.process.ensure_configs()
|
||||
conn_dir = os.path.join(self._swanctl_dir, 'connections')
|
||||
secrets_dir = os.path.join(self._swanctl_dir, 'secrets')
|
||||
self.assertTrue(os.path.isdir(conn_dir))
|
||||
self.assertTrue(os.path.isdir(secrets_dir))
|
||||
|
||||
conn_files = os.listdir(conn_dir)
|
||||
self.assertEqual(2, len(conn_files))
|
||||
for conn_file in conn_files:
|
||||
with open(os.path.join(conn_dir, conn_file)) as fh:
|
||||
content = fh.read()
|
||||
self.assertIn('connections {', content)
|
||||
self.assertIn('local_addrs', content)
|
||||
|
||||
secrets_file = os.path.join(secrets_dir, 'secrets.conf')
|
||||
self.assertTrue(os.path.exists(secrets_file))
|
||||
with open(secrets_file) as fh:
|
||||
secrets_content = fh.read()
|
||||
self.assertIn('secrets {', secrets_content)
|
||||
self.assertIn('shared-secret', secrets_content)
|
||||
@@ -248,3 +248,14 @@ class TestOvnLibreSwanDriver(TestOvnStrongSwanDriver):
|
||||
def setUp(self):
|
||||
super().setUp(driver=ovn_ipsec.OvnLibreSwanDriver,
|
||||
ipsec_process=libreswan_ipsec.LibreSwanProcess)
|
||||
|
||||
|
||||
class TestOvnSwanctlDriver(TestOvnStrongSwanDriver):
|
||||
def setUp(self):
|
||||
super().setUp(driver=ovn_ipsec.OvnSwanctlDriver,
|
||||
ipsec_process=strongswan_ipsec.SwanctlProcess)
|
||||
|
||||
def test_create_process(self):
|
||||
process = self.driver.create_process(
|
||||
FAKE_PROCESS_ID, self.vpnservice, FAKE_NAMESPACE_NAME)
|
||||
self.assertIsInstance(process, strongswan_ipsec.SwanctlProcess)
|
||||
@@ -0,0 +1,235 @@
|
||||
# Copyright 2026 OpenStack Foundation
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import os
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
import warnings
|
||||
|
||||
from neutron_lib import constants
|
||||
from oslo_config import cfg
|
||||
|
||||
from neutron_vpnaas.services.vpn.device_drivers import ipsec
|
||||
from neutron_vpnaas.services.vpn.device_drivers import strongswan_ipsec
|
||||
from neutron_vpnaas.tests import base
|
||||
from neutron_vpnaas.tests.unit.services.vpn.device_drivers import test_ipsec
|
||||
|
||||
FAKE_CONN1_ID = test_ipsec.FAKE_IPSEC_SITE_CONNECTION1_ID
|
||||
FAKE_CONN2_ID = test_ipsec.FAKE_IPSEC_SITE_CONNECTION2_ID
|
||||
FAKE_PROCESS_ID = 'foo-process-id'
|
||||
|
||||
SWANCTL_INSTALLED_STATUS = """%s: INSTALLED, ESTABLISHED, current (IPv4)
|
||||
child_sa: %s_child
|
||||
INSTALLED, ESTABLISHED, current (IPv4)
|
||||
""" % (FAKE_CONN2_ID, FAKE_CONN2_ID)
|
||||
|
||||
SWANCTL_CONNECTING_STATUS = """%s: CONNECTING, current (IPv4)
|
||||
""" % FAKE_CONN2_ID
|
||||
|
||||
SWANCTL_EMPTY_STATUS = 'No active Security Associations found.'
|
||||
|
||||
|
||||
class TestSwanctlProcess(base.BaseTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = cfg.CONF
|
||||
self.conf.register_opts(ipsec.ipsec_opts, 'ipsec')
|
||||
self.conf.register_opts(strongswan_ipsec.strongswan_opts, 'strongswan')
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.conf.set_override('state_path', self.tmpdir)
|
||||
self.vpnservice = copy.deepcopy(test_ipsec.FAKE_VPN_SERVICE)
|
||||
self.process = strongswan_ipsec.SwanctlProcess(
|
||||
self.conf,
|
||||
FAKE_PROCESS_ID,
|
||||
self.vpnservice,
|
||||
'qrouter-test')
|
||||
self._swanctl_dir = os.path.join(
|
||||
self.tmpdir, 'ipsec', FAKE_PROCESS_ID, 'etc', 'swanctl')
|
||||
|
||||
def test_extract_connection_status_installed(self):
|
||||
self.process._extract_and_swanctl_connection_status(
|
||||
SWANCTL_INSTALLED_STATUS)
|
||||
self.assertEqual(
|
||||
constants.ACTIVE,
|
||||
self.process.connection_status[FAKE_CONN2_ID]['status'])
|
||||
self.assertEqual(
|
||||
constants.ACTIVE,
|
||||
self.process.connection_status[
|
||||
'%s_child' % FAKE_CONN2_ID]['status'])
|
||||
|
||||
def test_extract_connection_status_connecting(self):
|
||||
self.process._extract_and_swanctl_connection_status(
|
||||
SWANCTL_CONNECTING_STATUS)
|
||||
self.assertEqual(
|
||||
constants.DOWN,
|
||||
self.process.connection_status[FAKE_CONN2_ID]['status'])
|
||||
|
||||
def test_extract_connection_status_empty(self):
|
||||
self.process.connection_status = {FAKE_CONN1_ID: {'status': 'foo'}}
|
||||
self.process._extract_and_swanctl_connection_status('')
|
||||
self.assertEqual({}, self.process.connection_status)
|
||||
|
||||
def test_check_swanctl_status_line(self):
|
||||
conn_id, status = self.process._check_swanctl_status_line(
|
||||
'INSTALLED, ESTABLISHED')
|
||||
self.assertIsNone(conn_id)
|
||||
self.assertEqual(constants.ACTIVE, status)
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess,
|
||||
'_wait_for_charon_ready')
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_start(self, mock_execute, _mock_wait):
|
||||
self.process.start()
|
||||
mock_execute.assert_has_calls([
|
||||
mock.call(['ipsec', 'start'], check_exit_code=False),
|
||||
mock.call(['swanctl', '--load-all']),
|
||||
mock.call(['swanctl', '--load-creds']),
|
||||
mock.call(['swanctl', '--initiate', '--child', FAKE_CONN1_ID]),
|
||||
mock.call(['swanctl', '--initiate', '--child', FAKE_CONN2_ID]),
|
||||
], any_order=False)
|
||||
|
||||
def test_start_without_namespace(self):
|
||||
self.process.namespace = None
|
||||
with mock.patch.object(
|
||||
strongswan_ipsec.SwanctlProcess, '_execute') as execute:
|
||||
self.process.start()
|
||||
execute.assert_not_called()
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_stop(self, mock_execute):
|
||||
self.process.stop()
|
||||
mock_execute.assert_called_once_with(
|
||||
['ipsec', 'stop'], check_exit_code=False)
|
||||
self.assertEqual({}, self.process.connection_status)
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_reload(self, mock_execute):
|
||||
self.process.reload()
|
||||
mock_execute.assert_called_once_with(['swanctl', '--load-all'])
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_reload_secrets(self, mock_execute):
|
||||
self.process.reload_secrets()
|
||||
mock_execute.assert_called_once_with(['swanctl', '--load-creds'])
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_get_status(self, mock_execute):
|
||||
mock_execute.return_value = SWANCTL_INSTALLED_STATUS
|
||||
status = self.process.get_status()
|
||||
self.assertEqual(SWANCTL_INSTALLED_STATUS, status)
|
||||
mock_execute.assert_called_once_with(
|
||||
['swanctl', '--list-sas'], check_exit_code=False)
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_active(self, mock_execute):
|
||||
mock_execute.return_value = SWANCTL_INSTALLED_STATUS
|
||||
self.assertTrue(self.process.active)
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess, '_execute')
|
||||
def test_active_empty_status(self, mock_execute):
|
||||
mock_execute.return_value = SWANCTL_EMPTY_STATUS
|
||||
self.assertFalse(self.process.active)
|
||||
|
||||
def test_ensure_configs(self):
|
||||
self.process.ensure_configs()
|
||||
conn_dir = os.path.join(self._swanctl_dir, 'connections')
|
||||
secrets_dir = os.path.join(self._swanctl_dir, 'secrets')
|
||||
self.assertTrue(os.path.isdir(conn_dir))
|
||||
self.assertTrue(os.path.isdir(secrets_dir))
|
||||
self.assertTrue(os.path.exists(
|
||||
os.path.join(conn_dir, '%s.conf' % FAKE_CONN1_ID)))
|
||||
self.assertTrue(os.path.exists(
|
||||
os.path.join(conn_dir, '%s.conf' % FAKE_CONN2_ID)))
|
||||
self.assertTrue(os.path.exists(
|
||||
os.path.join(secrets_dir, 'secrets.conf')))
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.SwanctlProcess,
|
||||
'_get_strongswan_piddir', return_value='/var/run')
|
||||
def test_execute_uses_namespace_wrapper(self, _mock_piddir):
|
||||
with mock.patch(
|
||||
'neutron_vpnaas.services.vpn.device_drivers.strongswan_ipsec.'
|
||||
'ip_lib.IPWrapper') as ip_wrapper_cls:
|
||||
ip_wrapper = ip_wrapper_cls.return_value
|
||||
self.process._execute(['swanctl', '--list-sas'])
|
||||
ip_wrapper.netns.execute.assert_called_once()
|
||||
cmd = ip_wrapper.netns.execute.call_args[0][0]
|
||||
self.assertTrue(
|
||||
any('neutron-vpn-netns-wrapper' in arg for arg in cmd))
|
||||
self.assertIn('--cmd=swanctl,--list-sas', cmd[-1])
|
||||
|
||||
@mock.patch.object(strongswan_ipsec.StrongSwanProcess,
|
||||
'_get_strongswan_piddir', return_value='/var/run')
|
||||
def test_strongswan_process_deprecation_warning(self, _mock_piddir):
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter('always')
|
||||
strongswan_ipsec.StrongSwanProcess(
|
||||
self.conf, 'id', self.vpnservice, mock.ANY)
|
||||
self.assertTrue(any(
|
||||
issubclass(w.category, DeprecationWarning) for w in caught))
|
||||
|
||||
|
||||
class TestSwanctlConfigGeneration(test_ipsec.BaseIPsecDeviceDriver):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp(
|
||||
driver=strongswan_ipsec.SwanctlDriver,
|
||||
ipsec_process=strongswan_ipsec.SwanctlProcess)
|
||||
self.conf.register_opts(strongswan_ipsec.strongswan_opts, 'strongswan')
|
||||
self.conf.set_override('state_path', '/tmp')
|
||||
self.process = strongswan_ipsec.SwanctlProcess(
|
||||
self.conf, 'foo-process-id', self.vpnservice, mock.ANY)
|
||||
self.swanctl_template = self.conf.strongswan.swanctl_config_template
|
||||
self.swanctl_secrets_template = (
|
||||
self.conf.strongswan.swanctl_secrets_template)
|
||||
|
||||
def test_swanctl_config_template(self):
|
||||
actual = self.process._gen_config_content(
|
||||
self.swanctl_template, self.vpnservice)
|
||||
self.assertIn('connections {', actual)
|
||||
self.assertIn(FAKE_CONN1_ID, actual)
|
||||
self.assertIn(FAKE_CONN2_ID, actual)
|
||||
self.assertIn('local_addrs = [ 60.0.0.4 ]', actual)
|
||||
self.assertIn('remote_addrs = [ 60.0.0.5 ]', actual)
|
||||
self.assertIn('%s_child' % FAKE_CONN1_ID, actual)
|
||||
|
||||
def test_swanctl_secrets_template(self):
|
||||
actual = self.process._gen_config_content(
|
||||
self.swanctl_secrets_template, self.vpnservice)
|
||||
self.assertIn('secrets {', actual)
|
||||
self.assertIn('type: shared-secret', actual)
|
||||
self.assertIn('0scGFzc3dvcmQ=', actual)
|
||||
|
||||
|
||||
class TestSwanctlDriver(test_ipsec.IPsecStrongswanDeviceDriverLegacy):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp(
|
||||
driver=strongswan_ipsec.SwanctlDriver,
|
||||
ipsec_process=strongswan_ipsec.SwanctlProcess)
|
||||
|
||||
def test_create_process(self):
|
||||
process = self.driver.create_process(
|
||||
test_ipsec.FAKE_ROUTER_ID, self.vpnservice, 'qrouter-test')
|
||||
self.assertIsInstance(process, strongswan_ipsec.SwanctlProcess)
|
||||
|
||||
def test_status_handling_for_active_connection(self):
|
||||
self._test_status_handling_for_active_connection(
|
||||
SWANCTL_INSTALLED_STATUS)
|
||||
|
||||
def test_status_handling_for_downed_connection(self):
|
||||
self._test_status_handling_for_downed_connection(
|
||||
SWANCTL_CONNECTING_STATUS)
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Added support for ``swanctl`` (VICI protocol) as an alternative to the
|
||||
legacy stroke/starter interface in the strongSwan device driver. Operators
|
||||
can now enable swanctl mode by setting ``use_swanctl = True`` under the
|
||||
``[strongswan]`` configuration section. This enables compatibility with
|
||||
modern Linux distributions (Fedora, RHEL 9+, recent Ubuntu/Debian) that
|
||||
ship strongSwan without the deprecated stroke plugin.
|
||||
|
||||
deprecated:
|
||||
- |
|
||||
The ``use_swanctl`` configuration option under ``[strongswan]`` is now
|
||||
deprecated and will be removed in the 2027.2 release. Operators should
|
||||
migrate to using swanctl mode by default without requiring this option.
|
||||
|
||||
- |
|
||||
The ``StrongSwanProcess`` class is now deprecated and will be removed in a
|
||||
future release. Users should migrate to ``SwanctlProcess`` which is now the
|
||||
default behavior when ``use_swanctl`` is not explicitly set to False.
|
||||
Reference in new issue
Block a user