#!/usr/bin/env python3 # # Copyright (C) 2019-2020 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import os import re from sys import exit from ipaddress import IPv4Address from ipaddress import IPv4Network from ipaddress import IPv6Address from ipaddress import IPv6Network from ipaddress import summarize_address_range from netifaces import interfaces from shutil import rmtree from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.ifconfig import VTunIf from vyos.template import render from vyos.util import call from vyos.util import chown from vyos.util import chmod_600 from vyos.util import dict_search from vyos.validate import is_addr_assigned from vyos.validate import is_ipv6 from vyos.configverify import verify_vrf from vyos.configverify import verify_bridge_delete from vyos import ConfigError from vyos import airbag airbag.enable() user = 'openvpn' group = 'openvpn' cfg_file = '/run/openvpn/{ifname}.conf' def checkCertHeader(header, filename): """ Verify if filename contains specified header. Returns True if match is found, False if no match or file is not found """ if not os.path.isfile(filename): return False with open(filename, 'r') as f: for line in f: if re.match(header, line): return True return False def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: conf = config else: conf = Config() base = ['interfaces', 'openvpn'] openvpn = get_interface_dict(conf, base) openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) openvpn['daemon_user'] = user openvpn['daemon_group'] = group for addr_type in ['local_address', 'remote_address']: # Split the {local,remote}_address property into separate IPv4 and IPv6 # lists for proper validation/configuration if addr_type in openvpn: # actually only one ipv4 and one ipv6 address is allowed - validated # later on, thus keep this simple address_v4 = [] netmask_v4 = [] address_v6 = [] for addr in openvpn[addr_type]: if is_ipv6(addr): address_v6.append(addr) else: if addr_type == 'local_address' and addr in openvpn['local_address'] and 'subnet_mask' in openvpn['local_address'][addr]: netmask_v4.append(openvpn['local_address'][addr]['subnet_mask']) address_v4.append(addr) if address_v4: openvpn.update({addr_type + '_v4': address_v4}) if address_v6: openvpn.update({addr_type + '_v6': address_v6}) if netmask_v4: openvpn.update({'local_address_v4_netmask': netmask_v4}) del openvpn[addr_type] if dict_search('server.subnet', openvpn): # Split the subnet property into separate IPv4 and IPv6 lists for # proper validation/configuration subnet_v4 = [] subnet_v6 = [] for subnet in dict_search('server.subnet', openvpn): if is_ipv6(subnet): subnet_v6.append(subnet) else: subnet_v4.append(subnet) if subnet_v4: openvpn['server'].update({'subnet_v4': subnet_v4}) if subnet_v6: openvpn['server'].update({'subnet_v6': subnet_v6}) del openvpn['server']['subnet'] return openvpn def verify(openvpn): if 'deleted' in openvpn: verify_bridge_delete(openvpn) return None if 'mode' not in openvpn: raise ConfigError('Must specify OpenVPN operation mode!') # Check if we have disabled ncp and at the same time specified ncp-ciphers if 'encryption' in openvpn: if {'disable_ncp', 'ncp_ciphers'} <= set(openvpn.get('encryption')): raise ConfigError('Can not specify both "encryption disable-ncp" '\ 'and "encryption ncp-ciphers"') # # OpenVPN client mode - VERIFY # if openvpn['mode'] == 'client': if 'local_port' in openvpn: raise ConfigError('Cannot specify "local-port" in client mode') if 'local_host' in openvpn: raise ConfigError('Cannot specify "local-host" in client mode') if 'remote_host' not in openvpn: raise ConfigError('Must specify "remote-host" in client mode') if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Protocol "tcp-passive" is not valid in client mode') if dict_search('tls.dh_file', openvpn): raise ConfigError('Cannot specify "tls dh-file" in client mode') # # OpenVPN site-to-site - VERIFY # elif openvpn['mode'] == 'site-to-site': if dict_search('encryption.ncp_ciphers', openvpn): raise ConfigError('NCP ciphers can only be used in client or server mode') elif openvpn['mode'] == 'site-to-site' and 'is_bridge_member' not in openvpn: if not ('local_address_v4' in openvpn or 'local_address_v6' in openvpn): raise ConfigError('Must specify "local-address" or add interface to bridge') if 'local_address_v4' in openvpn and len(openvpn['local_address_v4']) > 1: raise ConfigError('Only one IPv4 local-address can be specified') if 'local_address_v6' in openvpn and len(openvpn['local_address_v6']) > 1: raise ConfigError('Only one IPv6 local-address can be specified') if 'remote_address_v4' in openvpn and len(openvpn['remote_address_v4']) > 1: raise ConfigError('Only one IPv4 remote-address can be specified') if 'remote_address_v6' in openvpn and len(openvpn['remote_address_v6']) > 1: raise ConfigError('Only one IPv6 remote-address can be specified') for host in (dict_search('remote_host', openvpn) or []): if host in (dict_search('remote_address_v4', openvpn) or vyos_dict_search('remote_address_v6', openvpn) or []): raise ConfigError('"remote-address" and "remote-host" can not be the same') if 'local_address_v4' in openvpn: if not ({'remote_address', 'local_address_v4_netmask'} <= set(openvpn)): raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address" or IPv4 "local-address subnet"') if 'remote_address_v4' in openvpn and not 'local_address_v4' in openvpn: raise ConfigError('IPv4 "remote-address" requires IPv4 "local-address"') if 'local_address_v4' in openvpn and not 'remote_address_v4' in openvpn: raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address"') if 'remote_address_v6' in openvpn and not 'local_address_v6' in openvpn: raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"') if 'local_address_v6' in openvpn and not 'remote_address_v6' in openvpn: raise ConfigError('IPv6 "local-address" requires IPv6 "remote-address"') if openvpn['device_type'] == 'tun': if not ('remote_address_v4' in openvpn or 'remote_address_v6' in openvpn): raise ConfigError('Must specify "remote-address"') if (dict_search('local_address_v4', openvpn) == vyos_dict_search('remote_address_v4', openvpn) or dict_search('local_address_v6', openvpn) == vyos_dict_search('remote_address_v6', openvpn)): raise ConfigError('"local-address" and "remote-address" cannot be the same') if dict_search('local_host', openvpn) in vyos_dict_search('local_address_v4', openvpn) or vyos_dict_search('local_address_v6', openvpn): raise ConfigError('"local-address" cannot be the same as "local-host"') else: # checks for client-server or site-to-site bridged if 'local_address' in openvpn or 'remote_address' in openvpn: raise ConfigError('Cannot specify "local-address" or "remote-address" ' \ 'in client/server or bridge mode') # # OpenVPN server mode - VERIFY # if openvpn['mode'] == 'server': if openvpn['protocol'] == 'tcp-active': raise ConfigError('Protocol "tcp-active" is not valid in server mode') if 'remote_port' in openvpn: raise ConfigError('Cannot specify "remote-port" in server mode') if 'remote_host' in openvpn: raise ConfigError('Cannot specify "remote-host" in server mode') tmp = dict_search('tls.dh_file', openvpn) if 'tls' in openvpn: if 'dh_file' not in openvpn['tls']: if 'key_file' in openvpn['tls'] and not checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls']['key_file']): raise ConfigError('Must specify "tls dh-file" when not using EC keys in server mode') if len(dict_search('server.subnet_v4', openvpn) or []) > 1: raise ConfigError('Cannot specify more than 1 IPv4 server subnet') if len(dict_search('server.subnet_v6', openvpn) or []) > 1: raise ConfigError('Cannot specify more than 1 IPv6 server subnet') for client in (dict_search('client', openvpn) or []): if len(client['ip']) > 1 or len(client['ipv6_ip']) > 1: raise ConfigError(f'Server client "{client["name"]}": cannot specify more than 1 IPv4 and 1 IPv6 IP') tmp = dict_search('server.subnet_v4', openvpn) if tmp: subnet = IPv4Network(tmp[0].replace(' ', '/')) if openvpn['device_type'] == 'tun' and subnet.prefixlen > 29: raise ConfigError('Server subnets smaller than /29 with device type "tun" are not supported') elif openvpn['device_type'] == 'tap' and subnet.prefixlen > 30: raise ConfigError('Server subnets smaller than /30 with device type "tap" are not supported') for client in (dict_search('client', openvpn) or []): if client['ip'] and not IPv4Address(client['ip'][0]) in subnet: raise ConfigError(f'Client "{client["name"]}" IP {client["ip"][0]} not in server subnet {subnet}') else: if 'is_bridge_member' not in openvpn: raise ConfigError('Must specify "server subnet" or add interface to bridge in server mode') if dict_search('server.client_ip_pool', openvpn): if not (dict_search('server.client_ip_pool.start', openvpn) and vyos_dict_search('server.client_ip_pool.stop', openvpn)): raise ConfigError('Server client-ip-pool requires both start and stop addresses') else: v4PoolStart = IPv4Address(dict_search('server.client_ip_pool.start', openvpn)) v4PoolStop = IPv4Address(dict_search('server.client_ip_pool.stop', openvpn)) if v4PoolStart > v4PoolStop: raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') v4PoolSize = int(v4PoolStop) - int(v4PoolStart) if v4PoolSize >= 65536: raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.') v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) for client in (dict_search('client', openvpn) or []): if client['ip']: for v4PoolNet in v4PoolNets: if IPv4Address(client['ip'][0]) in v4PoolNet: print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.') if dict_search('server.subnet_v6', openvpn): if not dict_search('server.subnet_v4', openvpn): raise ConfigError('IPv6 server requires an IPv4 server subnet') tmp = dict_search('client_ipv6_pool.base', openvpn) if tmp: if not dict_search('server.client_ip_pool', openvpn): raise ConfigError('IPv6 server pool requires an IPv4 server pool') if int(tmp.split('/')[1]) >= 112: raise ConfigError('IPv6 server pool must be larger than /112') # # todo - weird logic # v6PoolStart = IPv6Address(tmp) v6PoolStop = IPv6Network((v6PoolStart, openvpn['server_ipv6_pool_prefixlen']), strict=False)[-1] # don't remove the parentheses, it's a 2-tuple v6PoolSize = int(v6PoolStop) - int(v6PoolStart) if int(openvpn['server_ipv6_pool_prefixlen']) > 96 else 65536 if v6PoolSize < v4PoolSize: raise ConfigError(f'IPv6 server pool must be at least as large as the IPv4 pool (current sizes: IPv6={v6PoolSize} IPv4={v4PoolSize})') v6PoolNets = list(summarize_address_range(v6PoolStart, v6PoolStop)) for client in (dict_search('client', openvpn) or []): if client['ipv6_ip']: for v6PoolNet in v6PoolNets: if IPv6Address(client['ipv6_ip'][0]) in v6PoolNet: print(f'Warning: Client "{client["name"]}" IP {client["ipv6_ip"][0]} is in server IP pool, it is not reserved for this client.') else: for route in (dict_search('server.push_route', openvpn) or []): if is_ipv6(route): raise ConfigError('IPv6 push-route requires an IPv6 server subnet') #for client in openvpn ['client']: # if client['ipv6_ip']: # raise ConfigError(f'Server client "{client["name"]}" IPv6 IP requires an IPv6 server subnet') # if client['ipv6_push_route']: # raise ConfigError(f'Server client "{client["name"]} IPv6 push-route requires an IPv6 server subnet"') # if client['ipv6_subnet']: # raise ConfigError(f'Server client "{client["name"]} IPv6 subnet requires an IPv6 server subnet"') else: # checks for both client and site-to-site go here if dict_search('server.reject_unconfigured_clients', openvpn): raise ConfigError('Option reject-unconfigured-clients only supported in server mode') if 'replace_default_route' in openvpn and 'remote_host' not in openvpn: raise ConfigError('Cannot set "replace-default-route" without "remote-host"') # # OpenVPN common verification section # not depending on any operation mode # # 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']): raise ConfigError('local-host IP address "{local_host}" not assigned' \ ' to any interface'.format(**openvpn)) # TCP active if openvpn['protocol'] == 'tcp-active': if 'local_port' in openvpn: raise ConfigError('Cannot specify "local-port" with "tcp-active"') if 'remote_host' in openvpn: raise ConfigError('Must specify "remote-host" with "tcp-active"') # shared secret and TLS if not ('shared_secret_key_file' in openvpn or 'tls' in openvpn): raise ConfigError('Must specify one of "shared-secret-key-file" and "tls"') if {'shared_secret_key_file', 'tls'} <= set(openvpn): raise ConfigError('Can only specify one of "shared-secret-key-file" and "tls"') if openvpn['mode'] in ['client', 'server']: if 'tls' not in openvpn: raise ConfigError('Must specify "tls" for server and client mode') # # TLS/encryption # if 'shared_secret_key_file' in openvpn: if dict_search('encryption.cipher', openvpn) in ['aes128gcm', 'aes192gcm', 'aes256gcm']: raise ConfigError('GCM encryption with shared-secret-key-file not supported') file = dict_search('shared_secret_key_file', openvpn) if file and not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', file): raise ConfigError(f'Specified shared-secret-key-file "{file}" is not valid') if 'tls' in openvpn: if 'ca_cert_file' not in openvpn['tls']: raise ConfigError('Must specify "tls ca-cert-file"') if not (openvpn['mode'] == 'client' and 'auth_file' in openvpn['tls']): if 'cert_file' not in openvpn['tls']: raise ConfigError('Missing "tls cert-file"') if 'key_file' not in openvpn['tls']: raise ConfigError('Missing "tls key-file"') if {'auth_file', 'crypt_file'} <= set(openvpn['tls']): raise ConfigError('TLS auth and crypt are mutually exclusive') file = dict_search('tls.ca_cert_file', openvpn) if file and not checkCertHeader('-----BEGIN CERTIFICATE-----', file): raise ConfigError(f'Specified ca-cert-file "{file}" is invalid') file = dict_search('tls.auth_file', openvpn) if file and not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', file): raise ConfigError(f'Specified auth-file "{file}" is invalid') file = dict_search('tls.cert_file', openvpn) if file and not checkCertHeader('-----BEGIN CERTIFICATE-----', file): raise ConfigError(f'Specified cert-file "{file}" is invalid') file = dict_search('tls.key_file', openvpn) if file and not checkCertHeader('-----BEGIN (?:RSA |EC )?PRIVATE KEY-----', file): raise ConfigError(f'Specified key-file "{file}" is not valid') file = dict_search('tls.crypt_file', openvpn) if file and not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', file): raise ConfigError(f'Specified TLS crypt-file "{file}" is invalid') file = dict_search('tls.crl_file', openvpn) if file and not checkCertHeader('-----BEGIN X509 CRL-----', file): raise ConfigError(f'Specified crl-file "{file} not valid') file = dict_search('tls.dh_file', openvpn) if file and not checkCertHeader('-----BEGIN DH PARAMETERS-----', file): raise ConfigError(f'Specified dh-file "{file}" is not valid') tmp = dict_search('tls.role', openvpn) if tmp: if openvpn['mode'] in ['client', 'server']: if not dict_search('tls.auth_file', openvpn): raise ConfigError('Cannot specify "tls role" in client-server mode') if tmp == 'active': if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"') if dict_search('tls.dh_file', openvpn): raise ConfigError('Cannot specify "tls dh-file" when "tls role" is "active"') elif tmp == 'passive': if openvpn['protocol'] == 'tcp-active': raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"') if not dict_search('tls.dh_file', openvpn): raise ConfigError('Must specify "tls dh-file" when "tls role" is "passive"') file = dict_search('tls.key_file', openvpn) if file and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', file): if dict_search('tls.dh_file', openvpn): print('Warning: using dh-file and EC keys simultaneously will ' \ 'lead to DH ciphers being used instead of ECDH') if dict_search('encryption.cipher', openvpn) == 'none': print('Warning: "encryption none" was specified!') print('No encryption will be performed and data is transmitted in ' \ 'plain text over the network!') # # Auth user/pass # if (dict_search('authentication.username', openvpn) and not dict_search('authentication.password', openvpn)): raise ConfigError('Password for authentication is missing') if (dict_search('authentication.password', openvpn) and not dict_search('authentication.username', openvpn)): raise ConfigError('Username for authentication is missing') verify_vrf(openvpn) return None def generate(openvpn): interface = openvpn['ifname'] directory = os.path.dirname(cfg_file.format(**openvpn)) # we can't know in advance which clients have been removed, # thus all client configs will be removed and re-added on demand ccd_dir = os.path.join(directory, 'ccd', interface) if os.path.isdir(ccd_dir): rmtree(ccd_dir, ignore_errors=True) if 'deleted' in openvpn or 'disable' in openvpn: return None # create config directory on demand directories = [] directories.append(f'{directory}/status') directories.append(f'{directory}/ccd/{interface}') for onedir in directories: if not os.path.exists(onedir): os.makedirs(onedir, 0o755) chown(onedir, user, group) # Fix file permissons for keys fix_permissions = [] tmp = dict_search('shared_secret_key_file', openvpn) if tmp: fix_permissions.append(openvpn['shared_secret_key_file']) tmp = dict_search('tls.key_file', openvpn) if tmp: fix_permissions.append(tmp) # Generate User/Password authentication file if 'auth' in openvpn: with open(openvpn['auth_user_pass_file'], 'w') as f: f.write('{}\n{}'.format(openvpn['auth_user'], openvpn['auth_pass'])) # also change permission on auth file fix_permissions.append(openvpn['auth_user_pass_file']) else: # delete old auth file if present if os.path.isfile(openvpn['auth_user_pass_file']): os.remove(openvpn['auth_user_pass_file']) # Generate client specific configuration if dict_search('server.client', openvpn): for client, client_config in dict_search('server.client', openvpn).items(): client_file = os.path.join(ccd_dir, client) # Our client need's to know its subnet mask ... client_config['subnet'] = dict_search('server.subnet_v4', openvpn) render(client_file, 'openvpn/client.conf.tmpl', client_config, trim_blocks=True, user=user, group=group) # we need to support quoting of raw parameters from OpenVPN CLI # see https://phabricator.vyos.net/T1632 render(cfg_file.format(**openvpn), 'openvpn/server.conf.tmpl', openvpn, trim_blocks=True, formater=lambda _: _.replace(""", '"'), user=user, group=group) # Fixup file permissions for file in fix_permissions: chmod_600(file) return None def apply(openvpn): interface = openvpn['ifname'] call(f'systemctl stop openvpn@{interface}.service') # Do some cleanup when OpenVPN is disabled/deleted if 'deleted' in openvpn or 'disable' in openvpn: # cleanup old configuration files cleanup = [] cleanup.append(cfg_file.format(**openvpn)) cleanup.append(openvpn['auth_user_pass_file']) for file in cleanup: if os.path.isfile(file): os.unlink(file) if interface in interfaces(): VTunIf(interface).remove() return None # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process call(f'systemctl start openvpn@{interface}.service') conf = VTunIf.get_config() conf['device_type'] = openvpn['device_type'] o = VTunIf(interface, **conf) o.update(openvpn) return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)