diff options
Diffstat (limited to 'src/conf_mode/interfaces-openvpn.py')
-rwxr-xr-x | src/conf_mode/interfaces-openvpn.py | 334 |
1 files changed, 188 insertions, 146 deletions
diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 8a615ec62..b42765586 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -17,23 +17,20 @@ import os import re -from jinja2 import FileSystemLoader, Environment from copy import deepcopy -from sys import exit -from stat import S_IRUSR,S_IRWXU,S_IRGRP,S_IXGRP,S_IROTH,S_IXOTH -from grp import getgrnam -from ipaddress import ip_address,ip_network,IPv4Interface +from sys import exit,stderr +from ipaddress import IPv4Address,IPv4Network,summarize_address_range from netifaces import interfaces -from pwd import getpwnam from time import sleep from shutil import rmtree from vyos.config import Config -from vyos.defaults import directories as vyos_data_dir from vyos.ifconfig import VTunIf -from vyos.util import process_running, cmd, is_bridge_member +from vyos.util import call, is_bridge_member, chown, chmod_600, chmod_755 from vyos.validate import is_addr_assigned from vyos import ConfigError +from vyos.template import render + user = 'openvpn' group = 'openvpn' @@ -75,10 +72,14 @@ default_config_data = { 'server_domain': '', 'server_max_conn': '', 'server_dns_nameserver': [], + 'server_pool': False, + 'server_pool_start': '', + 'server_pool_stop': '', + 'server_pool_netmask': '', 'server_push_route': [], 'server_reject_unconfigured': False, 'server_subnet': '', - 'server_topology': '', + 'server_topology': 'net30', 'shared_secret_file': '', 'tls': False, 'tls_auth': '', @@ -97,32 +98,9 @@ default_config_data = { def get_config_name(intf): - cfg_file = r'/opt/vyatta/etc/openvpn/openvpn-{}.conf'.format(intf) + cfg_file = f'/run/openvpn/{intf}.conf' return cfg_file -def openvpn_mkdir(directory): - # create directory on demand - if not os.path.exists(directory): - os.mkdir(directory) - - # fix permissions - corresponds to mode 755 - os.chmod(directory, S_IRWXU|S_IRGRP|S_IXGRP|S_IROTH|S_IXOTH) - uid = getpwnam(user).pw_uid - gid = getgrnam(group).gr_gid - os.chown(directory, uid, gid) - -def fixup_permission(filename, permission=S_IRUSR): - """ - Check if the given file exists and change ownershit to root/vyattacfg - and appripriate file access permissions - default is user and group readable - """ - if os.path.isfile(filename): - os.chmod(filename, permission) - - # make file owned by root / vyattacfg - uid = getpwnam('root').pw_uid - gid = getgrnam('vyattacfg').gr_gid - os.chown(filename, uid, gid) def checkCertHeader(header, filename): """ @@ -139,6 +117,66 @@ def checkCertHeader(header, filename): return False +def getDefaultServer(network, topology, devtype): + """ + Gets the default server parameters for a "server" directive. + Currently only IPv4 routed but may be extended to support bridged and/or IPv6 in the future. + Logic from openvpn's src/openvpn/helper.c. + Returns a dict with addresses or False if the input parameters were incorrect. + """ + if not (topology and devtype): + return False + + if not (devtype == 'tun' or devtype == 'tap'): + return False + + if not network.prefixlen: + return False + elif (devtype == 'tun' and network.prefixlen > 29) or (devtype == 'tap' and network.prefixlen > 30): + return False + + server = { + 'local': '', + 'remote_netmask': '', + 'client_remote_netmask': '', + 'pool_start': '', + 'pool_stop': '', + 'pool_netmask': '' + } + + if devtype == 'tun': + if topology == 'net30' or topology == 'point-to-point': + server['local'] = network[1] + server['remote_netmask'] = network[2] + server['client_remote_netmask'] = server['local'] + + # pool start is 4th host IP in subnet (.4 in a /24) + server['pool_start'] = network[4] + + if network.prefixlen == 29: + server['pool_stop'] = network.broadcast_address + else: + # pool end is -4 from the broadcast address (.251 in a /24) + server['pool_stop'] = network[-5] + + elif topology == 'subnet': + server['local'] = network[1] + server['remote_netmask'] = str(network.netmask) + server['client_remote_netmask'] = server['remote_netmask'] + server['pool_start'] = network[2] + server['pool_stop'] = network[-3] + server['pool_netmask'] = server['remote_netmask'] + + elif devtype == 'tap': + server['local'] = network[1] + server['remote_netmask'] = str(network.netmask) + server['client_remote_netmask'] = server['remote_netmask'] + server['pool_start'] = network[2] + server['pool_stop'] = network[-2] + server['pool_netmask'] = server['remote_netmask'] + + return server + def get_config(): openvpn = deepcopy(default_config_data) conf = Config() @@ -308,10 +346,10 @@ def get_config(): # Server-mode subnet (from which client IPs are allocated) if conf.exists('server subnet'): - network = conf.return_value('server subnet') - tmp = IPv4Interface(network).with_netmask + # server_network is used later in this function + server_network = IPv4Network(conf.return_value('server subnet')) # convert the network in format: "192.0.2.0 255.255.255.0" for later use in template - openvpn['server_subnet'] = tmp.replace(r'/', ' ') + openvpn['server_subnet'] = server_network.with_netmask.replace(r'/', ' ') # Client-specific settings for client in conf.list_nodes('server client'): @@ -326,19 +364,6 @@ def get_config(): 'remote_netmask': '' } - # note: with "topology subnet", this is "<ip> <netmask>". - # with "topology p2p", this is "<ip> <our_ip>". - if openvpn['server_topology'] == 'subnet': - # we are only interested in the netmask portion of server_subnet - data['remote_netmask'] = openvpn['server_subnet'].split(' ')[1] - else: - # we need the server subnet in format 192.0.2.0/255.255.255.0 - subnet = openvpn['server_subnet'].replace(' ', r'/') - # get iterator over the usable hosts in the network - tmp = ip_network(subnet).hosts() - # OpenVPN always uses the subnets first available IP address - data['remote_netmask'] = list(tmp)[0] - # Option to disable client connection if conf.exists('disable'): data['disable'] = True @@ -349,13 +374,11 @@ def get_config(): # Route to be pushed to the client for network in conf.return_values('push-route'): - tmp = IPv4Interface(network).with_netmask - data['push_route'].append(tmp.replace(r'/', ' ')) + data['push_route'].append(IPv4Network(network).with_netmask.replace(r'/', ' ')) # Subnet belonging to the client for network in conf.return_values('subnet'): - tmp = IPv4Interface(network).with_netmask - data['subnet'].append(tmp.replace(r'/', ' ')) + data['subnet'].append(IPv4Network(network).with_netmask.replace(r'/', ' ')) # Append to global client list openvpn['client'].append(data) @@ -363,6 +386,19 @@ def get_config(): # re-set configuration level conf.set_level('interfaces openvpn ' + openvpn['intf']) + # Server client IP pool + if conf.exists('server client-ip-pool'): + openvpn['server_pool'] = True + + if conf.exists('server client-ip-pool start'): + openvpn['server_pool_start'] = conf.return_value('server client-ip-pool start') + + if conf.exists('server client-ip-pool stop'): + openvpn['server_pool_stop'] = conf.return_value('server client-ip-pool stop') + + if conf.exists('server client-ip-pool netmask'): + openvpn['server_pool_netmask'] = conf.return_value('server client-ip-pool netmask') + # DNS suffix to be pushed to all clients if conf.exists('server domain-name'): openvpn['server_domain'] = conf.return_value('server domain-name') @@ -378,8 +414,7 @@ def get_config(): # Route to be pushed to all clients if conf.exists('server push-route'): for network in conf.return_values('server push-route'): - tmp = IPv4Interface(network).with_netmask - openvpn['server_push_route'].append(tmp.replace(r'/', ' ')) + openvpn['server_push_route'].append(IPv4Network(network).with_netmask.replace(r'/', ' ')) # Reject connections from clients that are not explicitly configured if conf.exists('server reject-unconfigured-clients'): @@ -441,6 +476,26 @@ def get_config(): if not openvpn['tls_dh'] and openvpn['tls_key'] and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): openvpn['tls_dh'] = 'none' + # Set defaults where necessary. + # If any of the input parameters are missing or wrong, + # this will return False and no defaults will be set. + default_server = getDefaultServer(server_network, openvpn['server_topology'], openvpn['type']) + if default_server: + # server-bridge doesn't require a pool so don't set defaults for it + if not openvpn['bridge_member']: + openvpn['server_pool'] = True + if not openvpn['server_pool_start']: + openvpn['server_pool_start'] = default_server['pool_start'] + + if not openvpn['server_pool_stop']: + openvpn['server_pool_stop'] = default_server['pool_stop'] + + if not openvpn['server_pool_netmask']: + openvpn['server_pool_netmask'] = default_server['pool_netmask'] + + for client in openvpn['client']: + client['remote_netmask'] = default_server['client_remote_netmask'] + return openvpn def verify(openvpn): @@ -535,10 +590,42 @@ def verify(openvpn): if not openvpn['tls_dh'] and not checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): raise ConfigError('Must specify "tls dh-file" when not using EC keys in server mode') - if not openvpn['server_subnet']: + if openvpn['server_subnet']: + subnet = IPv4Network(openvpn['server_subnet'].replace(' ', '/')) + + if openvpn['type'] == 'tun' and subnet.prefixlen > 29: + raise ConfigError('Server subnets smaller than /29 with device type "tun" are not supported') + elif openvpn['type'] == 'tap' and subnet.prefixlen > 30: + raise ConfigError('Server subnets smaller than /30 with device type "tap" are not supported') + + for client in openvpn['client']: + if client['ip'] and not IPv4Address(client['ip']) in subnet: + raise ConfigError(f'Client IP "{client["ip"]}" not in server subnet "{subnet}"') + + else: if not openvpn['bridge_member']: raise ConfigError('Must specify "server subnet" or "bridge member interface" in server mode') + + if openvpn['server_pool']: + if not (openvpn['server_pool_start'] and openvpn['server_pool_stop']): + raise ConfigError('Server client-ip-pool requires both start and stop addresses in bridged mode') + else: + v4PoolStart = IPv4Address(openvpn['server_pool_start']) + v4PoolStop = IPv4Address(openvpn['server_pool_stop']) + if v4PoolStart > v4PoolStop: + raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') + if (int(v4PoolStop) - int(v4PoolStart) >= 65536): + raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop}], maximum is 65536 addresses.') + + v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) + for client in openvpn['client']: + if client['ip']: + for v4PoolNet in v4PoolNets: + if IPv4Address(client['ip']) in v4PoolNet: + print(f'Warning: Client "{client["name"]}" IP {client["ip"]} is in server IP pool, it is not reserved for this client.', + file=stderr) + else: # checks for both client and site-to-site go here if openvpn['server_reject_unconfigured']: @@ -665,143 +752,98 @@ def verify(openvpn): if not openvpn['auth_pass']: raise ConfigError('Password for authentication is missing') - # - # Client - # - subnet = openvpn['server_subnet'].replace(' ', '/') - for client in openvpn['client']: - if client['ip'] and not ip_address(client['ip']) in ip_network(subnet): - raise ConfigError('Client IP "{}" not in server subnet "{}'.format(client['ip'], subnet)) - return None def generate(openvpn): if openvpn['deleted'] or openvpn['disable']: return None - # Prepare Jinja2 template loader from files - tmpl_path = os.path.join(vyos_data_dir['data'], 'templates', 'openvpn') - fs_loader = FileSystemLoader(tmpl_path) - env = Environment(loader=fs_loader) - interface = openvpn['intf'] directory = os.path.dirname(get_config_name(interface)) - # we can't know which clients were deleted, remove all client configs - if os.path.isdir(os.path.join(directory, 'ccd', interface)): - rmtree(os.path.join(directory, 'ccd', interface), ignore_errors=True) + # we can't know in advance which clients have been, + # remove all client configs + ccd_dir = os.path.join(directory, 'ccd', interface) + if os.path.isdir(ccd_dir): + rmtree(ccd_dir, ignore_errors=True) # create config directory on demand - openvpn_mkdir(directory) - # create status directory on demand - openvpn_mkdir(directory + '/status') - # create client config dir on demand - openvpn_mkdir(directory + '/ccd') - # crete client config dir per interface on demand - openvpn_mkdir(directory + '/ccd/' + interface) + 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 - fixup_permission(openvpn['shared_secret_file']) - fixup_permission(openvpn['tls_key']) + fix_permissions = [] + fix_permissions.append(openvpn['shared_secret_file']) + fix_permissions.append(openvpn['tls_key']) # Generate User/Password authentication file + user_auth_file = f'/tmp/openvpn-{interface}-pw' if openvpn['auth']: - auth_file = '/tmp/openvpn-{}-pw'.format(interface) - with open(auth_file, 'w') as f: + with open(user_auth_file, 'w') as f: f.write('{}\n{}'.format(openvpn['auth_user'], openvpn['auth_pass'])) - - fixup_permission(auth_file) + # also change permission on auth file + fix_permissions.append(user_auth_file) else: # delete old auth file if present - if os.path.isfile('/tmp/openvpn-{}-pw'.format(interface)): - os.remove('/tmp/openvpn-{}-pw'.format(interface)) - - # get numeric uid/gid - uid = getpwnam(user).pw_uid - gid = getgrnam(group).gr_gid + if os.path.isfile(user_auth_file): + os.remove(user_auth_file) # Generate client specific configuration for client in openvpn['client']: - client_file = directory + '/ccd/' + interface + '/' + client['name'] - tmpl = env.get_template('client.conf.tmpl') - client_text = tmpl.render(client) - with open(client_file, 'w') as f: - f.write(client_text) - os.chown(client_file, uid, gid) - - tmpl = env.get_template('server.conf.tmpl') - config_text = tmpl.render(openvpn) + client_file = os.path.join(ccd_dir, client['name']) + render(client_file, 'openvpn/client.conf.tmpl', client) + chown(client_file, user, group) + # we need to support quoting of raw parameters from OpenVPN CLI # see https://phabricator.vyos.net/T1632 - config_text = config_text.replace(""",'"') - with open(get_config_name(interface), 'w') as f: - f.write(config_text) - os.chown(get_config_name(interface), uid, gid) + render(get_config_name(interface), 'openvpn/server.conf.tmpl', openvpn, + formater=lambda _: _.replace(""", '"')) + chown(get_config_name(interface), user, group) + + # Fixup file permissions + for file in fix_permissions: + chmod_600(file) return None def apply(openvpn): - pidfile = '/var/run/openvpn/{}.pid'.format(openvpn['intf']) - - # Always stop OpenVPN service. We can not send a SIGUSR1 for restart of the - # service as the configuration is not re-read. Stop daemon only if it's - # running - it could have died or killed by someone evil - if process_running(pidfile): - command = 'start-stop-daemon' - command += ' --stop ' - command += ' --quiet' - command += ' --oknodo' - command += ' --pidfile ' + pidfile - cmd(command) - - # cleanup old PID file - if os.path.isfile(pidfile): - os.remove(pidfile) + interface = openvpn['intf'] + call(f'systemctl stop openvpn@{interface}.service') # Do some cleanup when OpenVPN is disabled/deleted if openvpn['deleted'] or openvpn['disable']: # cleanup old configuration file - if os.path.isfile(get_config_name(openvpn['intf'])): - os.remove(get_config_name(openvpn['intf'])) + if os.path.isfile(get_config_name(interface)): + os.remove(get_config_name(interface)) # cleanup client config dir - directory = os.path.dirname(get_config_name(openvpn['intf'])) - if os.path.isdir(os.path.join(directory, 'ccd', openvpn['intf'])): - rmtree(os.path.join(directory, 'ccd', openvpn['intf']), ignore_errors=True) - - # cleanup auth file - if os.path.isfile('/tmp/openvpn-{}-pw'.format(openvpn['intf'])): - os.remove('/tmp/openvpn-{}-pw'.format(openvpn['intf'])) + directory = os.path.dirname(get_config_name(interface)) + ccd_dir = os.path.join(directory, 'ccd', interface) + if os.path.isdir(ccd_dir): + rmtree(ccd_dir, ignore_errors=True) return None # On configuration change we need to wait for the 'old' interface to # vanish from the Kernel, if it is not gone, OpenVPN will report: # ERROR: Cannot ioctl TUNSETIFF vtun10: Device or resource busy (errno=16) - while openvpn['intf'] in interfaces(): + while interface in interfaces(): sleep(0.250) # 250ms # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process - command = 'start-stop-daemon' - command += ' --start ' - command += ' --quiet' - command += ' --oknodo' - command += ' --pidfile ' + pidfile - command += ' --exec /usr/sbin/openvpn' - # now pass arguments to openvpn binary - command += ' --' - command += ' --daemon openvpn-' + openvpn['intf'] - command += ' --config ' + get_config_name(openvpn['intf']) - - # execute assembled command - cmd(command) + call(f'systemctl start openvpn@{interface}.service') # better late then sorry ... but we can only set interface alias after # OpenVPN has been launched and created the interface cnt = 0 - while openvpn['intf'] not in interfaces(): + while interface not in interfaces(): # If VPN tunnel can't be established because the peer/server isn't # (temporarily) available, the vtun interface never becomes registered # with the kernel, and the commit would hang if there is no bail out @@ -816,7 +858,7 @@ def apply(openvpn): try: # we need to catch the exception if the interface is not up due to # reason stated above - o = VTunIf(openvpn['intf']) + o = VTunIf(interface) # update interface description used e.g. within SNMP o.set_alias(openvpn['description']) # IPv6 address autoconfiguration @@ -834,7 +876,7 @@ def apply(openvpn): # TAP interface needs to be brought up explicitly if openvpn['type'] == 'tap': if not openvpn['disable']: - VTunIf(openvpn['intf']).set_admin_state('up') + VTunIf(interface).set_admin_state('up') return None |