diff options
Diffstat (limited to 'src/conf_mode/interfaces-openvpn.py')
-rwxr-xr-x | src/conf_mode/interfaces-openvpn.py | 417 |
1 files changed, 127 insertions, 290 deletions
diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 6bd269e97..f34e4f7fe 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# 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 @@ -17,262 +17,27 @@ import os import re -from jinja2 import Template +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 netifaces import interfaces -from psutil import pid_exists from pwd import getpwnam -from subprocess import Popen, PIPE from time import sleep +from shutil import rmtree -from vyos import ConfigError from vyos.config import Config -from vyos.ifconfig import Interface +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.validate import is_addr_assigned +from vyos import ConfigError user = 'openvpn' group = 'openvpn' -# Please be careful if you edit the template. -config_tmpl = """ -### Autogenerated by interfaces-openvpn.py ### -# -# See https://community.openvpn.net/openvpn/wiki/Openvpn24ManPage -# for individual keyword definition - -{% if description %} -# {{ description }} -{% endif %} - -verb 3 -status /opt/vyatta/etc/openvpn/status/{{ intf }}.status 30 -writepid /var/run/openvpn/{{ intf }}.pid - -dev-type {{ type }} -dev {{ intf }} -user {{ uid }} -group {{ gid }} -persist-key -iproute /usr/libexec/vyos/system/unpriv-ip - -proto {% if 'tcp-active' in protocol -%}tcp-client{% elif 'tcp-passive' in protocol -%}tcp-server{% else %}udp{% endif %} - -{%- if local_host %} -local {{ local_host }} -{% endif %} - -{%- if local_port %} -lport {{ local_port }} -{% endif %} - -{%- if remote_port %} -rport {{ remote_port }} -{% endif %} - -{%- if remote_host %} -{% for remote in remote_host -%} -remote {{ remote }} -{% endfor -%} -{% endif %} - -{%- if shared_secret_file %} -secret {{ shared_secret_file }} -{% endif %} - -{%- if persistent_tunnel %} -persist-tun -{% endif %} - -{%- if mode %} -{%- if 'client' in mode %} -# -# OpenVPN Client mode -# -client -nobind -{%- elif 'server' in mode %} -# -# OpenVPN Server mode -# -mode server -tls-server -keepalive {{ ping_interval }} {{ ping_restart }} -management /tmp/openvpn-mgmt-intf unix - -{%- if server_topology %} -topology {% if 'point-to-point' in server_topology %}p2p{% else %}subnet{% endif %} -{% endif %} - -{% for ns in server_dns_nameserver -%} -push "dhcp-option DNS {{ ns }}" -{% endfor -%} - -{% for route in server_push_route -%} -push "route {{ route }}" -{% endfor -%} - -{%- if server_domain %} -push "dhcp-option DOMAIN {{ server_domain }}" -{% endif %} - -{%- if server_max_conn %} -max-clients {{ server_max_conn }} -{% endif %} - -{%- if bridge_member %} -server-bridge nogw -{%- else %} -server {{ server_subnet }} -{% endif %} - -{%- if server_reject_unconfigured %} -ccd-exclusive -{% endif %} - -{%- else %} -# -# OpenVPN site-2-site mode -# -ping {{ ping_interval }} -ping-restart {{ ping_restart }} - -{%- if local_address_subnet %} -ifconfig {{ local_address }} {{ local_address_subnet }} -{% elif remote_address %} -ifconfig {{ local_address }} {{ remote_address }} -{% endif %} - -{% endif %} -{% endif %} - -{%- if tls_ca_cert %} -ca {{ tls_ca_cert }} -{% endif %} - -{%- if tls_cert %} -cert {{ tls_cert }} -{% endif %} - -{%- if tls_key %} -key {{ tls_key }} -{% endif %} - -{%- if tls_crl %} -crl-verify {{ tls_crl }} -{% endif %} - -{%- if tls_version_min %} -tls-version-min {{tls_version_min}} -{% endif %} - -{%- if tls_dh %} -dh {{ tls_dh }} -{% endif %} - -{%- if tls_auth %} -tls-auth {{tls_auth}} -{% endif %} - -{%- if 'active' in tls_role %} -tls-client -{%- elif 'passive' in tls_role %} -tls-server -{% endif %} - -{%- if redirect_gateway %} -push "redirect-gateway {{ redirect_gateway }}" -{% endif %} - -{%- if compress_lzo %} -compress lzo -{% endif %} - -{%- if hash %} -auth {{ hash }} -{% endif %} - -{%- if encryption %} -{%- if 'des' in encryption %} -cipher des-cbc -{%- elif '3des' in encryption %} -cipher des-ede3-cbc -{%- elif 'bf128' in encryption %} -cipher bf-cbc -keysize 128 -{%- elif 'bf256' in encryption %} -cipher bf-cbc -keysize 25 -{%- elif 'aes128gcm' in encryption %} -cipher aes-128-gcm -{%- elif 'aes128' in encryption %} -cipher aes-128-cbc -{%- elif 'aes192gcm' in encryption %} -cipher aes-192-gcm -{%- elif 'aes192' in encryption %} -cipher aes-192-cbc -{%- elif 'aes256gcm' in encryption %} -cipher aes-256-gcm -{%- elif 'aes256' in encryption %} -cipher aes-256-cbc -{% endif %} -{% endif %} - -{%- if ncp_ciphers %} -ncp-ciphers {{ncp_ciphers}} -{% endif %} -{%- if disable_ncp %} -ncp-disable -{% endif %} - -{%- if auth %} -auth-user-pass /tmp/openvpn-{{ intf }}-pw -auth-retry nointeract -{% endif %} - -{%- if client %} -client-config-dir /opt/vyatta/etc/openvpn/ccd/{{ intf }} -{% endif %} - -# DEPRECATED This option will be removed in OpenVPN 2.5 -# Until OpenVPN v2.3 the format of the X.509 Subject fields was formatted like this: -# /C=US/L=Somewhere/CN=John Doe/emailAddress=john@example.com In addition the old -# behaviour was to remap any character other than alphanumeric, underscore ('_'), -# dash ('-'), dot ('.'), and slash ('/') to underscore ('_'). The X.509 Subject -# string as returned by the tls_id environmental variable, could additionally -# contain colon (':') or equal ('='). When using the --compat-names option, this -# old formatting and remapping will be re-enabled again. This is purely implemented -# for compatibility reasons when using older plug-ins or scripts which does not -# handle the new formatting or UTF-8 characters. -# -# See https://phabricator.vyos.net/T1512 -compat-names - -{% for option in options -%} -{{ option }} -{% endfor -%} -""" - -client_tmpl = """ -### Autogenerated by interfaces-openvpn.py ### - -ifconfig-push {{ ip }} {{ remote_netmask }} -{% for route in push_route -%} -push "route {{ route }}" -{% endfor -%} - -{% for net in subnet -%} -iroute {{ net }} -{% endfor -%} - -{%- if disable %} -disable -{% endif %} -""" - default_config_data = { 'address': [], 'auth_user': '', @@ -287,6 +52,10 @@ default_config_data = { 'encryption': '', 'hash': '', 'intf': '', + 'ipv6_autoconf': 0, + 'ipv6_eui64_prefix': '', + 'ipv6_forwarding': 1, + 'ipv6_dup_addr_detect': 1, 'ping_restart': '60', 'ping_interval': '10', 'local_address': '', @@ -297,7 +66,7 @@ default_config_data = { 'ncp_ciphers': '', 'options': [], 'persistent_tunnel': False, - 'protocol': '', + 'protocol': 'udp', 'redirect_gateway': '', 'remote_address': '', 'remote_host': [], @@ -318,6 +87,7 @@ default_config_data = { 'tls_crl': '', 'tls_dh': '', 'tls_key': '', + 'tls_crypt': '', 'tls_role': '', 'tls_version_min': '', 'type': 'tun', @@ -325,9 +95,6 @@ default_config_data = { 'gid': group, } -def subprocess_cmd(command): - p = Popen(command, stdout=PIPE, shell=True) - p.communicate() def get_config_name(intf): cfg_file = r'/opt/vyatta/etc/openvpn/openvpn-{}.conf'.format(intf) @@ -360,7 +127,7 @@ def fixup_permission(filename, permission=S_IRUSR): def checkCertHeader(header, filename): """ Verify if filename contains specified header. - Returns True on success or on file not found to not trigger the exceptions + Returns True if match is found, False if no match or file is not found """ if not os.path.isfile(filename): return False @@ -370,17 +137,17 @@ def checkCertHeader(header, filename): if re.match(header, line): return True - return True + return False def get_config(): openvpn = deepcopy(default_config_data) conf = Config() # determine tagNode instance - try: - openvpn['intf'] = os.environ['VYOS_TAGNODE_VALUE'] - except KeyError as E: - print("Interface not specified") + if 'VYOS_TAGNODE_VALUE' not in os.environ: + raise ConfigError('Interface (VYOS_TAGNODE_VALUE) not specified') + + openvpn['intf'] = os.environ['VYOS_TAGNODE_VALUE'] # Check if interface instance has been removed if not conf.exists('interfaces openvpn ' + openvpn['intf']): @@ -482,10 +249,25 @@ def get_config(): if conf.exists('local-port'): openvpn['local_port'] = conf.return_value('local-port') + # Enable acquisition of IPv6 address using stateless autoconfig (SLAAC) + if conf.exists('ipv6 address autoconf'): + openvpn['ipv6_autoconf'] = 1 + + # Get prefix for IPv6 addressing based on MAC address (EUI-64) + if conf.exists('ipv6 address eui64'): + openvpn['ipv6_eui64_prefix'] = conf.return_value('ipv6 address eui64') + + # Disable IPv6 forwarding on this interface + if conf.exists('ipv6 disable-forwarding'): + openvpn['ipv6_forwarding'] = 0 + + # IPv6 Duplicate Address Detection (DAD) tries + if conf.exists('ipv6 dup-addr-detect-transmits'): + openvpn['ipv6_dup_addr_detect'] = int(conf.return_value('ipv6 dup-addr-detect-transmits')) + # OpenVPN operation mode if conf.exists('mode'): - mode = conf.return_value('mode') - openvpn['mode'] = mode + openvpn['mode'] = conf.return_value('mode') # Additional OpenVPN options if conf.exists('openvpn-option'): @@ -633,6 +415,11 @@ def get_config(): openvpn['tls_key'] = conf.return_value('tls key-file') openvpn['tls'] = True + # File containing key to encrypt control channel packets + if conf.exists('tls crypt-file'): + openvpn['tls_crypt'] = conf.return_value('tls crypt-file') + openvpn['tls'] = True + # Role in TLS negotiation if conf.exists('tls role'): openvpn['tls_role'] = conf.return_value('tls role') @@ -641,6 +428,7 @@ def get_config(): # Minimum required TLS version if conf.exists('tls tls-version-min'): openvpn['tls_version_min'] = conf.return_value('tls tls-version-min') + openvpn['tls'] = True if conf.exists('shared-secret-key-file'): openvpn['shared_secret_file'] = conf.return_value('shared-secret-key-file') @@ -648,12 +436,25 @@ def get_config(): if conf.exists('use-lzo-compression'): openvpn['compress_lzo'] = True + # Special case when using EC certificates: + # if key-file is EC and dh-file is unset, set tls_dh to 'none' + if not openvpn['tls_dh'] and openvpn['tls_key'] and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): + openvpn['tls_dh'] = 'none' + return openvpn def verify(openvpn): if openvpn['deleted']: + interface = openvpn['intf'] + is_member, bridge = is_bridge_member(interface) + if is_member: + # can not use a f'' formatted-string here as bridge would not get + # expanded in the print statement + raise ConfigError('Can not delete interface "{0}" as it ' \ + 'is a member of bridge "{1}"!'.format(interface, bridge)) return None + if not openvpn['mode']: raise ConfigError('Must specify OpenVPN operation mode') @@ -682,7 +483,7 @@ def verify(openvpn): if not openvpn['remote_host']: raise ConfigError('Must specify "remote-host" in client mode') - if openvpn['tls_dh']: + if openvpn['tls_dh'] and openvpn['tls_dh'] != 'none': raise ConfigError('Cannot specify "tls dh-file" in client mode') # @@ -732,8 +533,8 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-passive' and len(openvpn['remote_host']) > 1: raise ConfigError('Cannot specify more than 1 "remote-host" with "tcp-passive"') - if not openvpn['tls_dh']: - raise ConfigError('Must specify "tls dh-file" in server mode') + 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 not openvpn['bridge_member']: @@ -800,6 +601,9 @@ def verify(openvpn): if not openvpn['tls_key']: raise ConfigError('Must specify "tls key-file"') + if openvpn['tls_auth'] and openvpn['tls_crypt']: + raise ConfigError('TLS auth and crypt are mutually exclusive') + if not checkCertHeader('-----BEGIN CERTIFICATE-----', openvpn['tls_ca_cert']): raise ConfigError('Specified ca-cert-file "{}" is invalid'.format(openvpn['tls_ca_cert'])) @@ -812,14 +616,18 @@ def verify(openvpn): raise ConfigError('Specified cert-file "{}" is invalid'.format(openvpn['tls_cert'])) if openvpn['tls_key']: - if not checkCertHeader('-----BEGIN (?:RSA )?PRIVATE KEY-----', openvpn['tls_key']): + if not checkCertHeader('-----BEGIN (?:RSA |EC )?PRIVATE KEY-----', openvpn['tls_key']): raise ConfigError('Specified key-file "{}" is not valid'.format(openvpn['tls_key'])) + if openvpn['tls_crypt']: + if not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', openvpn['tls_crypt']): + raise ConfigError('Specified TLS crypt-file "{}" is invalid'.format(openvpn['tls_crypt'])) + if openvpn['tls_crl']: if not checkCertHeader('-----BEGIN X509 CRL-----', openvpn['tls_crl']): raise ConfigError('Specified crl-file "{} not valid'.format(openvpn['tls_crl'])) - if openvpn['tls_dh']: + if openvpn['tls_dh'] and openvpn['tls_dh'] != 'none': if not checkCertHeader('-----BEGIN DH PARAMETERS-----', openvpn['tls_dh']): raise ConfigError('Specified dh-file "{}" is not valid'.format(openvpn['tls_dh'])) @@ -832,7 +640,7 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"') - if openvpn['tls_dh']: + if openvpn['tls_dh'] and openvpn['tls_dh'] != 'none': raise ConfigError('Cannot specify "tls dh-file" when "tls role" is "active"') elif openvpn['tls_role'] == 'passive': @@ -842,6 +650,12 @@ def verify(openvpn): if not openvpn['tls_dh']: raise ConfigError('Must specify "tls dh-file" when "tls role" is "passive"') + if openvpn['tls_key'] and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls_key']): + if openvpn['tls_dh'] and openvpn['tls_dh'] != 'none': + print('Warning: using dh-file and EC keys simultaneously will lead to DH ciphers being used instead of ECDH') + else: + print('Diffie-Hellman prime file is unspecified, assuming ECDH') + # # Auth user/pass # @@ -857,20 +671,27 @@ def verify(openvpn): # subnet = openvpn['server_subnet'].replace(' ', '/') for client in openvpn['client']: - if not ip_address(client['ip']) in ip_network(subnet): + 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) + # create config directory on demand openvpn_mkdir(directory) # create status directory on demand @@ -892,6 +713,11 @@ def generate(openvpn): fixup_permission(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 @@ -899,19 +725,17 @@ def generate(openvpn): # Generate client specific configuration for client in openvpn['client']: client_file = directory + '/ccd/' + interface + '/' + client['name'] - tmpl = Template(client_tmpl) + 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 = Template(config_tmpl) + tmpl = env.get_template('server.conf.tmpl') config_text = tmpl.render(openvpn) - # 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) @@ -919,20 +743,18 @@ def generate(openvpn): return None def apply(openvpn): - pid = 0 pidfile = '/var/run/openvpn/{}.pid'.format(openvpn['intf']) - if os.path.isfile(pidfile): - pid = 0 - with open(pidfile, 'r') as f: - pid = int(f.read()) # 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 pid_exists(pid): - cmd = 'start-stop-daemon --stop --quiet' - cmd += ' --pidfile ' + pidfile - subprocess_cmd(cmd) + 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): @@ -946,11 +768,12 @@ def apply(openvpn): # cleanup client config dir directory = os.path.dirname(get_config_name(openvpn['intf'])) - if os.path.isdir(directory + '/ccd/' + openvpn['intf']): - try: - os.remove(directory + '/ccd/' + openvpn['intf'] + '/*') - except: - pass + 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'])) return None @@ -962,16 +785,19 @@ def apply(openvpn): # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process - cmd = 'start-stop-daemon --start --quiet' - cmd += ' --pidfile ' + pidfile - cmd += ' --exec /usr/sbin/openvpn' + command = 'start-stop-daemon' + command += ' --start ' + command += ' --quiet' + command += ' --oknodo' + command += ' --pidfile ' + pidfile + command += ' --exec /usr/sbin/openvpn' # now pass arguments to openvpn binary - cmd += ' --' - cmd += ' --daemon openvpn-' + openvpn['intf'] - cmd += ' --config ' + get_config_name(openvpn['intf']) + command += ' --' + command += ' --daemon openvpn-' + openvpn['intf'] + command += ' --config ' + get_config_name(openvpn['intf']) # execute assembled command - subprocess_cmd(cmd) + cmd(command) # better late then sorry ... but we can only set interface alias after # OpenVPN has been launched and created the interface @@ -991,14 +817,25 @@ def apply(openvpn): try: # we need to catch the exception if the interface is not up due to # reason stated above - Interface(openvpn['intf']).set_alias(openvpn['description']) + o = VTunIf(openvpn['intf']) + # update interface description used e.g. within SNMP + o.set_alias(openvpn['description']) + # IPv6 address autoconfiguration + o.set_ipv6_autoconf(openvpn['ipv6_autoconf']) + # IPv6 EUI-based address + o.set_ipv6_eui64_address(openvpn['ipv6_eui64_prefix']) + # IPv6 forwarding + o.set_ipv6_forwarding(openvpn['ipv6_forwarding']) + # IPv6 Duplicate Address Detection (DAD) tries + o.set_ipv6_dad_messages(openvpn['ipv6_dup_addr_detect']) + except: pass # TAP interface needs to be brought up explicitly if openvpn['type'] == 'tap': if not openvpn['disable']: - Interface(openvpn['intf']).set_state('up') + VTunIf(openvpn['intf']).set_admin_state('up') return None |