summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniil Baturin <daniil@vyos.io>2024-03-28 17:08:33 +0100
committerGitHub <noreply@github.com>2024-03-28 17:08:33 +0100
commitba05d82eadce6f1efe68b18b30baa44e12ba70c9 (patch)
treebc274955026352d8644c467732ff9eddaae80d24
parent6a637704fac75bec87d1dd5474c02efd7efbdeec (diff)
parent679b78356cbda4de15f96a7f22d4a98037dbeea4 (diff)
downloadvyos-1x-ba05d82eadce6f1efe68b18b30baa44e12ba70c9.tar.gz
vyos-1x-ba05d82eadce6f1efe68b18b30baa44e12ba70c9.zip
Merge pull request #2965 from lucasec/t5872
T5872: ipsec remote access VPN: support dhcp-interface.
-rw-r--r--data/templates/ipsec/swanctl/remote_access.j22
-rw-r--r--interface-definitions/vpn_ipsec.xml.in1
-rwxr-xr-xsmoketest/scripts/cli/test_vpn_ipsec.py69
-rwxr-xr-xsrc/conf_mode/vpn_ipsec.py49
-rwxr-xr-xsrc/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook77
5 files changed, 131 insertions, 67 deletions
diff --git a/data/templates/ipsec/swanctl/remote_access.j2 b/data/templates/ipsec/swanctl/remote_access.j2
index bce8684fe..af7f2994e 100644
--- a/data/templates/ipsec/swanctl/remote_access.j2
+++ b/data/templates/ipsec/swanctl/remote_access.j2
@@ -4,7 +4,7 @@
{% set esp = esp_group[rw_conf.esp_group] %}
ra-{{ name }} {
remote_addrs = %any
- local_addrs = {{ rw_conf.local_address if rw_conf.local_address is vyos_defined else '%any' }}
+ local_addrs = {{ rw_conf.local_address if rw_conf.local_address is not vyos_defined('any') else '%any' }} # dhcp:{{ rw_conf.dhcp_interface if rw_conf.dhcp_interface is vyos_defined else 'no' }}
proposals = {{ ike_group[rw_conf.ike_group] | get_esp_ike_cipher | join(',') }}
version = {{ ike.key_exchange[4:] if ike.key_exchange is vyos_defined else "0" }}
send_certreq = no
diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in
index 44ca1c7a0..833019d68 100644
--- a/interface-definitions/vpn_ipsec.xml.in
+++ b/interface-definitions/vpn_ipsec.xml.in
@@ -825,6 +825,7 @@
#include <include/ipsec/esp-group.xml.i>
#include <include/ipsec/ike-group.xml.i>
#include <include/ipsec/local-address.xml.i>
+ #include <include/dhcp-interface.xml.i>
#include <include/ipsec/local-traffic-selector.xml.i>
#include <include/ipsec/replay-window.xml.i>
<leafNode name="timeout">
diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py
index 09e10a2c4..1a5e47144 100755
--- a/smoketest/scripts/cli/test_vpn_ipsec.py
+++ b/smoketest/scripts/cli/test_vpn_ipsec.py
@@ -31,7 +31,7 @@ nhrp_path = ['protocols', 'nhrp']
base_path = ['vpn', 'ipsec']
charon_file = '/etc/strongswan.d/charon.conf'
-dhcp_waiting_file = '/tmp/ipsec_dhcp_waiting'
+dhcp_interfaces_file = '/tmp/ipsec_dhcp_interfaces'
swanctl_file = '/etc/swanctl/swanctl.conf'
peer_ip = '203.0.113.45'
@@ -178,10 +178,10 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase):
self.cli_commit()
- self.assertTrue(os.path.exists(dhcp_waiting_file))
+ self.assertTrue(os.path.exists(dhcp_interfaces_file))
- dhcp_waiting = read_file(dhcp_waiting_file)
- self.assertIn(f'{interface}.{vif}', dhcp_waiting) # Ensure dhcp-failed interface was added for dhclient hook
+ dhcp_interfaces = read_file(dhcp_interfaces_file)
+ self.assertIn(f'{interface}.{vif}', dhcp_interfaces) # Ensure dhcp interface was added for dhclient hook
self.cli_delete(ethernet_path + [interface, 'vif', vif, 'address'])
@@ -898,5 +898,66 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase):
self.tearDownPKI()
+ def test_remote_access_dhcp_fail_handling(self):
+ # Skip process check - connection is not created for this test
+ self.skip_process_check = True
+
+ # Interface for dhcp-interface
+ self.cli_set(ethernet_path + [interface, 'vif', vif, 'address', 'dhcp']) # Use VLAN to avoid getting IP from qemu dhcp server
+
+ # This is a known to be good configuration for Microsoft Windows 10 and Apple iOS 17
+ self.setupPKI()
+
+ ike_group = 'IKE-RW'
+ esp_group = 'ESP-RW'
+
+ conn_name = 'vyos-rw'
+ ip_pool_name = 'ra-rw-ipv4'
+ username = 'vyos'
+ password = 'secret'
+ ike_lifetime = '7200'
+ eap_lifetime = '3600'
+ local_id = 'ipsec.vyos.net'
+
+ name_server = '172.16.254.100'
+ prefix = '172.16.250.0/28'
+
+ # IKE
+ self.cli_set(base_path + ['ike-group', ike_group, 'key-exchange', 'ikev2'])
+ self.cli_set(base_path + ['ike-group', ike_group, 'lifetime', ike_lifetime])
+ self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'dh-group', '14'])
+ self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'encryption', 'aes256'])
+ self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'hash', 'sha512'])
+
+ # ESP
+ self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', eap_lifetime])
+ self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'encryption', 'aes256'])
+ self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'hash', 'sha512'])
+
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-id', local_id])
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-users', 'username', username, 'password', password])
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'server-mode', 'x509'])
+
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'certificate', peer_name])
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'ca-certificate', ca_name])
+
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'esp-group', esp_group])
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'ike-group', ike_group])
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'dhcp-interface', f'{interface}.{vif}'])
+ self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'pool', ip_pool_name])
+ self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'name-server', name_server])
+ self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'prefix', prefix])
+
+ self.cli_commit()
+
+ self.assertTrue(os.path.exists(dhcp_interfaces_file))
+
+ dhcp_interfaces = read_file(dhcp_interfaces_file)
+ self.assertIn(f'{interface}.{vif}', dhcp_interfaces) # Ensure dhcp interface was added for dhclient hook
+
+ self.cli_delete(ethernet_path + [interface, 'vif', vif, 'address'])
+
+ self.tearDownPKI()
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py
index 388f2a709..ebfb21903 100755
--- a/src/conf_mode/vpn_ipsec.py
+++ b/src/conf_mode/vpn_ipsec.py
@@ -75,7 +75,7 @@ KEY_PATH = f'{swanctl_dir}/private/'
CA_PATH = f'{swanctl_dir}/x509ca/'
CRL_PATH = f'{swanctl_dir}/x509crl/'
-DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_waiting'
+DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_interfaces'
def get_config(config=None):
if config:
@@ -94,6 +94,7 @@ def get_config(config=None):
with_recursive_defaults=True,
with_pki=True)
+ ipsec['dhcp_interfaces'] = 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'])
@@ -229,6 +230,32 @@ def verify(ipsec):
if 'remote_access' in ipsec:
if 'connection' in ipsec['remote_access']:
for name, ra_conf in ipsec['remote_access']['connection'].items():
+ if 'local_address' not in ra_conf and 'dhcp_interface' not in ra_conf:
+ raise ConfigError(f"Missing local-address or dhcp-interface on remote-access connection {name}")
+
+ if 'dhcp_interface' in ra_conf:
+ dhcp_interface = ra_conf['dhcp_interface']
+
+ verify_interface_exists(dhcp_interface)
+ dhcp_base = directories['isc_dhclient_dir']
+
+ if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'):
+ raise ConfigError(f"Invalid dhcp-interface on remote-access connection {name}")
+
+ ipsec['dhcp_interfaces'].add(dhcp_interface)
+
+ address = get_dhcp_address(dhcp_interface)
+ count = 0
+ while not address and count < dhcp_wait_attempts:
+ address = get_dhcp_address(dhcp_interface)
+ count += 1
+ sleep(dhcp_wait_sleep)
+
+ if not address:
+ ipsec['dhcp_no_address'][f'ra_{name}'] = dhcp_interface
+ print(f"Failed to get address from dhcp-interface on remote-access connection {name} -- skipped")
+ continue
+
if 'esp_group' in ra_conf:
if 'esp_group' not in ipsec or ra_conf['esp_group'] not in ipsec['esp_group']:
raise ConfigError(f"Invalid esp-group on {name} remote-access config")
@@ -386,6 +413,8 @@ def verify(ipsec):
if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'):
raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}")
+ ipsec['dhcp_interfaces'].add(dhcp_interface)
+
address = get_dhcp_address(dhcp_interface)
count = 0
while not address and count < dhcp_wait_attempts:
@@ -394,7 +423,7 @@ def verify(ipsec):
sleep(dhcp_wait_sleep)
if not address:
- ipsec['dhcp_no_address'][peer] = dhcp_interface
+ ipsec['dhcp_no_address'][f'peer_{peer}'] = dhcp_interface
print(f"Failed to get address from dhcp-interface on site-to-site peer {peer} -- skipped")
continue
@@ -503,9 +532,9 @@ def generate(ipsec):
render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes})
return
- if ipsec['dhcp_no_address']:
+ if ipsec['dhcp_interfaces']:
with open(DHCP_HOOK_IFLIST, 'w') as f:
- f.write(" ".join(ipsec['dhcp_no_address'].values()))
+ f.write(" ".join(ipsec['dhcp_interfaces']))
elif os.path.exists(DHCP_HOOK_IFLIST):
os.unlink(DHCP_HOOK_IFLIST)
@@ -522,13 +551,23 @@ def generate(ipsec):
if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']:
for rw, rw_conf in ipsec['remote_access']['connection'].items():
+ if f'ra_{rw}' in ipsec['dhcp_no_address']:
+ continue
+
+ local_ip = ''
+ if 'local_address' in rw_conf:
+ local_ip = rw_conf['local_address']
+ elif 'dhcp_interface' in rw_conf:
+ local_ip = get_dhcp_address(rw_conf['dhcp_interface'])
+
+ ipsec['remote_access']['connection'][rw]['local_address'] = local_ip
if 'authentication' in rw_conf and 'x509' in rw_conf['authentication']:
generate_pki_files_x509(ipsec['pki'], rw_conf['authentication']['x509'])
if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']:
for peer, peer_conf in ipsec['site_to_site']['peer'].items():
- if peer in ipsec['dhcp_no_address']:
+ if f'peer_{peer}' in ipsec['dhcp_no_address']:
continue
if peer_conf['authentication']['mode'] == 'x509':
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook
index e6edc1ac3..ebb100e8b 100755
--- a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook
@@ -14,69 +14,32 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-if [ "$reason" == "REBOOT" ] || [ "$reason" == "EXPIRE" ]; then
- return 0
-fi
-
-DHCP_HOOK_IFLIST="/tmp/ipsec_dhcp_waiting"
+DHCP_HOOK_IFLIST="/tmp/ipsec_dhcp_interfaces"
-if [ -f $DHCP_HOOK_IFLIST ] && [ "$reason" == "BOUND" ]; then
- if grep -qw $interface $DHCP_HOOK_IFLIST; then
- sudo rm $DHCP_HOOK_IFLIST
- sudo /usr/libexec/vyos/conf_mode/vpn_ipsec.py
- return 0
- fi
+if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then
+ exit 0
fi
-if [ "$old_ip_address" == "$new_ip_address" ] && [ "$reason" == "BOUND" ]; then
- return 0
+# Re-generate the config on the following events:
+# - BOUND: always re-generate
+# - RENEW: re-generate if the IP address changed
+# - REBIND: re-generate if the IP address changed
+if [ "$reason" == "RENEW" ] || [ "$reason" == "REBIND" ]; then
+ if [ "$old_ip_address" == "$new_ip_address" ]; then
+ exit 0
+ fi
+elif [ "$reason" != "BOUND" ]; then
+ exit 0
fi
-python3 - <<PYEND
-import os
-import re
-
-from vyos.utils.process import call
-from vyos.utils.process import cmd
-from vyos.utils.file import read_file
-from vyos.utils.file import write_file
-
-SWANCTL_CONF="/etc/swanctl/swanctl.conf"
-
-def ipsec_down(ip_address):
- # This prevents the need to restart ipsec and kill all active connections, only the stale connection is closed
- status = cmd('sudo ipsec statusall')
- connection_name = None
- for line in status.split("\n"):
- if line.find(ip_address) > 0:
- regex_match = re.search(r'(peer_[^:\[]+)', line)
- if regex_match:
- connection_name = regex_match[1]
- break
- if connection_name:
- call(f'sudo ipsec down {connection_name}')
+# Best effort wait for any active commit to finish
+sudo python3 - <<PYEND
+from vyos.utils.commit import wait_for_commit_lock
if __name__ == '__main__':
- interface = os.getenv('interface')
- new_ip = os.getenv('new_ip_address')
- old_ip = os.getenv('old_ip_address')
-
- if os.path.exists(SWANCTL_CONF):
- conf_lines = read_file(SWANCTL_CONF)
- found = False
- to_match = f'# dhcp:{interface}'
-
- for i, line in enumerate(conf_lines):
- if line.find(to_match) > 0:
- conf_lines[i] = line.replace(old_ip, new_ip)
- found = True
-
- if found:
- write_file(SWANCTL_CONF, conf_lines)
- ipsec_down(old_ip)
- call('sudo ipsec rereadall')
- call('sudo ipsec reload')
- call('sudo swanctl -q')
-
+ wait_for_commit_lock()
exit(0)
PYEND
+
+# Now re-generate the config
+sudo /usr/libexec/vyos/conf_mode/vpn_ipsec.py