Skip to content

Commit

Permalink
Merge pull request #3975 from lucasec/t6183
Browse files Browse the repository at this point in the history
T6183: interfaces openvpn: suppport specifying IP protocol version
  • Loading branch information
c-po authored Aug 20, 2024
2 parents 538930c + 18ea367 commit 2277371
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 3 deletions.
12 changes: 9 additions & 3 deletions data/templates/openvpn/server.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ dev-type {{ device_type }}
dev {{ ifname }}
persist-key
{% if protocol is vyos_defined('tcp-active') %}
proto tcp-client
proto tcp{{ protocol_modifier }}-client
{% elif protocol is vyos_defined('tcp-passive') %}
proto tcp-server
proto tcp{{ protocol_modifier }}-server
{% else %}
proto udp
proto udp{{ protocol_modifier }}
{% endif %}
{% if local_host is vyos_defined %}
local {{ local_host }}
Expand Down Expand Up @@ -63,6 +63,9 @@ nobind
#
# OpenVPN Server mode
#
{% if ip_version is vyos_defined('ipv6') %}
bind ipv6only
{% endif %}
mode server
tls-server
{% if server is vyos_defined %}
Expand Down Expand Up @@ -131,6 +134,9 @@ plugin "{{ plugin_dir }}/openvpn-otp.so" "otp_secrets=/config/auth/openvpn/{{ if
#
# OpenVPN site-2-site mode
#
{% if ip_version is vyos_defined('ipv6') %}
bind ipv6only
{% endif %}
ping {{ keep_alive.interval }}
ping-restart {{ keep_alive.failure_count }}

Expand Down
28 changes: 28 additions & 0 deletions interface-definitions/interfaces_openvpn.xml.in
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,34 @@
</properties>
<defaultValue>udp</defaultValue>
</leafNode>
<leafNode name="ip-version">
<properties>
<help>Force OpenVPN to use a specific IP protocol version</help>
<completionHelp>
<list>auto ipv4 ipv6 dual-stack</list>
</completionHelp>
<valueHelp>
<format>auto</format>
<description>Select one IP protocol to use based on local or remote host</description>
</valueHelp>
<valueHelp>
<format>_ipv4</format>
<description>Accept connections on or initate connections to IPv4 addresses only</description>
</valueHelp>
<valueHelp>
<format>_ipv6</format>
<description>Accept connections on or initate connections to IPv6 addresses only</description>
</valueHelp>
<valueHelp>
<format>dual-stack</format>
<description>Accept connections on both protocols simultaneously (only supported in server mode)</description>
</valueHelp>
<constraint>
<regex>(auto|ipv4|ipv6|dual-stack)</regex>
</constraint>
</properties>
<defaultValue>auto</defaultValue>
</leafNode>
<leafNode name="remote-address">
<properties>
<help>IP address of remote end of tunnel</help>
Expand Down
186 changes: 186 additions & 0 deletions smoketest/scripts/cli/test_interfaces_openvpn.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,67 @@ def test_openvpn_client_interfaces(self):
interface = f'vtun{ii}'
self.assertNotIn(interface, interfaces())

def test_openvpn_client_ip_version(self):
# Test the client mode behavior combined with different IP protocol versions

interface = 'vtun10'
remote_host = '192.0.2.10'
remote_host_v6 = 'fd00::2:10'
path = base_path + [interface]
auth_hash = 'sha1'

# Default behavior: client uses uspecified protocol version (udp)
self.cli_set(path + ['device-type', 'tun'])
self.cli_set(path + ['encryption', 'data-ciphers', 'aes256'])
self.cli_set(path + ['hash', auth_hash])
self.cli_set(path + ['mode', 'client'])
self.cli_set(path + ['persistent-tunnel'])
self.cli_set(path + ['protocol', 'udp'])
self.cli_set(path + ['remote-host', remote_host])
self.cli_set(path + ['remote-port', remote_port])
self.cli_set(path + ['tls', 'ca-certificate', 'ovpn_test'])
self.cli_set(path + ['tls', 'certificate', 'ovpn_test'])
self.cli_set(path + ['vrf', vrf_name])
self.cli_set(path + ['authentication', 'username', interface+'user'])
self.cli_set(path + ['authentication', 'password', interface+'secretpw'])

self.cli_commit()

config_file = f'/run/openvpn/{interface}.conf'
config = read_file(config_file)

self.assertIn(f'dev vtun10', config)
self.assertIn(f'dev-type tun', config)
self.assertIn(f'persist-key', config)
self.assertIn(f'proto udp', config)
self.assertIn(f'rport {remote_port}', config)
self.assertIn(f'remote {remote_host}', config)
self.assertIn(f'persist-tun', config)

# IPv4 only: client usees udp4 protocol
self.cli_set(path + ['ip-version', 'ipv4'])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp4', config)

# IPv6 only: client uses udp6 protocol
self.cli_set(path + ['ip-version', 'ipv6'])
self.cli_delete(path + ['remote-host', remote_host])
self.cli_set(path + ['remote-host', remote_host_v6])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp6', config)

# IPv6 dual-stack: not allowed in client mode
self.cli_set(path + ['ip-version', 'dual-stack'])
with self.assertRaises(ConfigSessionError):
self.cli_commit()

self.cli_delete(base_path)
self.cli_commit()

def test_openvpn_server_verify(self):
# Create one OpenVPN server interface and check required verify() stages
interface = 'vtun5000'
Expand Down Expand Up @@ -453,6 +514,74 @@ def test_openvpn_server_subnet_topology(self):
interface = f'vtun{ii}'
self.assertNotIn(interface, interfaces())

def test_openvpn_server_ip_version(self):
# Test the server mode behavior combined with each IP protocol version

auth_hash = 'sha256'
port = '2000'

interface = 'vtun20'
subnet = '192.0.20.0/24'
path = base_path + [interface]

# Default behavior: client uses uspecified protocol version (udp)
self.cli_set(path + ['device-type', 'tun'])
self.cli_set(path + ['encryption', 'data-ciphers', 'aes192'])
self.cli_set(path + ['hash', auth_hash])
self.cli_set(path + ['mode', 'server'])
self.cli_set(path + ['local-port', port])
self.cli_set(path + ['server', 'subnet', subnet])
self.cli_set(path + ['server', 'topology', 'subnet'])

self.cli_set(path + ['replace-default-route'])
self.cli_set(path + ['tls', 'ca-certificate', 'ovpn_test'])
self.cli_set(path + ['tls', 'certificate', 'ovpn_test'])
self.cli_set(path + ['tls', 'dh-params', 'ovpn_test'])

self.cli_commit()

start_addr = inc_ip(subnet, '2')
stop_addr = last_host_address(subnet)

config_file = f'/run/openvpn/{interface}.conf'
config = read_file(config_file)

self.assertIn(f'dev {interface}', config)
self.assertIn(f'dev-type tun', config)
self.assertIn(f'persist-key', config)
self.assertIn(f'proto udp', config) # default protocol
self.assertIn(f'auth {auth_hash}', config)
self.assertIn(f'data-ciphers AES-192-CBC', config)
self.assertIn(f'topology subnet', config)
self.assertIn(f'lport {port}', config)
self.assertIn(f'push "redirect-gateway def1"', config)

# IPv4 only: server usees udp4 protocol
self.cli_set(path + ['ip-version', 'ipv4'])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp4', config)

# IPv6 only: server uses udp6 protocol + bind ipv6only
self.cli_set(path + ['ip-version', 'ipv6'])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp6', config)
self.assertIn(f'bind ipv6only', config)

# IPv6 dual-stack: server uses udp6 protocol without bind ipv6only
self.cli_set(path + ['ip-version', 'dual-stack'])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp6', config)
self.assertNotIn(f'bind ipv6only', config)

self.cli_delete(base_path)
self.cli_commit()

def test_openvpn_site2site_verify(self):
# Create one OpenVPN site2site interface and check required
# verify() stages
Expand Down Expand Up @@ -627,6 +756,63 @@ def test_openvpn_site2site_interfaces_tun(self):
self.assertNotIn(interface, interfaces())


def test_openvpn_site2site_ip_version(self):
# Test the site-to-site mode behavior combined with each IP protocol version

encryption_cipher = 'aes256'

interface = 'vtun30'
local_address = '192.0.30.1'
local_address_subnet = '255.255.255.252'
remote_address = '172.16.30.1'
path = base_path + [interface]
port = '3030'

self.cli_set(path + ['local-address', local_address])
self.cli_set(path + ['device-type', 'tun'])
self.cli_set(path + ['mode', 'site-to-site'])
self.cli_set(path + ['local-port', port])
self.cli_set(path + ['remote-port', port])
self.cli_set(path + ['shared-secret-key', 'ovpn_test'])
self.cli_set(path + ['remote-address', remote_address])
self.cli_set(path + ['encryption', 'cipher', encryption_cipher])

self.cli_commit()

config_file = f'/run/openvpn/{interface}.conf'
config = read_file(config_file)

self.assertIn(f'dev-type tun', config)
self.assertIn(f'ifconfig {local_address} {remote_address}', config)
self.assertIn(f'proto udp', config)
self.assertIn(f'dev {interface}', config)
self.assertIn(f'secret /run/openvpn/{interface}_shared.key', config)
self.assertIn(f'lport {port}', config)
self.assertIn(f'rport {port}', config)

# IPv4 only: server usees udp4 protocol
self.cli_set(path + ['ip-version', 'ipv4'])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp4', config)

# IPv6 only: server uses udp6 protocol + bind ipv6only
self.cli_set(path + ['ip-version', 'ipv6'])
self.cli_commit()

config = read_file(config_file)
self.assertIn(f'proto udp6', config)
self.assertIn(f'bind ipv6only', config)

# IPv6 dual-stack: not allowed in site-to-site mode
self.cli_set(path + ['ip-version', 'dual-stack'])
with self.assertRaises(ConfigSessionError):
self.cli_commit()

self.cli_delete(base_path)
self.cli_commit()

def test_openvpn_server_server_bridge(self):
# Create OpenVPN server interface using bridge.
# Validate configuration afterwards.
Expand Down
37 changes: 37 additions & 0 deletions src/conf_mode/interfaces_openvpn.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ def get_config(config=None):
openvpn['module_load_dco'] = {}
break

# Calculate the protocol modifier. This is concatenated to the protocol string to direct
# OpenVPN to use a specific IP protocol version. If unspecified, the kernel decides which
# type of socket to open. In server mode, an additional "ipv6-dual-stack" option forces
# binding the socket in IPv6 mode, which can also receive IPv4 traffic (when using the
# default "ipv6" mode, we specify "bind ipv6only" to disable kernel dual-stack behaviors).
if openvpn['ip_version'] == 'ipv4':
openvpn['protocol_modifier'] = '4'
elif openvpn['ip_version'] in ['ipv6', 'dual-stack']:
openvpn['protocol_modifier'] = '6'
else:
openvpn['protocol_modifier'] = ''

return openvpn

def is_ec_private_key(pki, cert_name):
Expand Down Expand Up @@ -257,13 +269,19 @@ def verify(openvpn):
if openvpn['protocol'] == 'tcp-passive':
raise ConfigError('Protocol "tcp-passive" is not valid in client mode')

if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack':
raise ConfigError('"ip-version dual-stack" is not supported in client mode')

if dict_search('tls.dh_params', openvpn):
raise ConfigError('Cannot specify "tls dh-params" in client mode')

#
# OpenVPN site-to-site - VERIFY
#
elif openvpn['mode'] == 'site-to-site':
if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack':
raise ConfigError('"ip-version dual-stack" is not supported in site-to-site mode')

if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn:
raise ConfigError('Must specify "local-address" or add interface to bridge')

Expand Down Expand Up @@ -487,6 +505,25 @@ def verify(openvpn):
# not depending on any operation mode
#

# verify that local_host/remote_host match with any ip_version override
# specified (if a dns name is specified for remote_host, no attempt is made
# to verify that record resolves to an address of the configured family)
if 'local_host' in openvpn:
if openvpn['ip_version'] == 'ipv4' and is_ipv6(openvpn['local_host']):
raise ConfigError('Cannot use an IPv6 "local-host" with "ip-version ipv4"')
elif openvpn['ip_version'] == 'ipv6' and is_ipv4(openvpn['local_host']):
raise ConfigError('Cannot use an IPv4 "local-host" with "ip-version ipv6"')
elif openvpn['ip_version'] == 'dual-stack':
raise ConfigError('Cannot use "local-host" with "ip-version dual-stack". "dual-stack" is only supported when OpenVPN binds to all available interfaces.')

if 'remote_host' in openvpn:
remote_hosts = dict_search('remote_host', openvpn)
for remote_host in remote_hosts:
if openvpn['ip_version'] == 'ipv4' and is_ipv6(remote_host):
raise ConfigError('Cannot use an IPv6 "remote-host" with "ip-version ipv4"')
elif openvpn['ip_version'] == 'ipv6' and is_ipv4(remote_host):
raise ConfigError('Cannot use an IPv4 "remote-host" with "ip-version ipv6"')

# verify specified IP address is present on any interface on this system
if 'local_host' in openvpn:
if not is_addr_assigned(openvpn['local_host']):
Expand Down

0 comments on commit 2277371

Please sign in to comment.