#!/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 <http://www.gnu.org/licenses/>.

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.configverify import verify_vrf
from vyos.configverify import verify_bridge_delete
from vyos.configverify import verify_diffie_hellman_length
from vyos.ifconfig import VTunIf
from vyos.template import render
from vyos.template import is_ipv4
from vyos.template import is_ipv6
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 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

    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 'local_address' not in openvpn and 'is_bridge_member' not in openvpn:
            raise ConfigError('Must specify "local-address" or add interface to bridge')

        if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1:
            raise ConfigError('Only one IPv4 local-address can be specified')

        if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1:
            raise ConfigError('Only one IPv6 local-address can be specified')

        if openvpn['device_type'] == 'tun':
            if 'remote_address' not in openvpn:
                raise ConfigError('Must specify "remote-address"')

        if 'remote_address' in openvpn:
            if len([addr for addr in openvpn['remote_address'] if is_ipv4(addr)]) > 1:
                raise ConfigError('Only one IPv4 remote-address can be specified')

            if len([addr for addr in openvpn['remote_address'] if is_ipv6(addr)]) > 1:
                raise ConfigError('Only one IPv6 remote-address can be specified')

            if not 'local_address' in openvpn:
                raise ConfigError('"remote-address" requires "local-address"')

            v4loAddr = [addr for addr in openvpn['local_address'] if is_ipv4(addr)]
            v4remAddr = [addr for addr in openvpn['remote_address'] if is_ipv4(addr)]
            if v4loAddr and not v4remAddr:
                raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address"')
            elif v4remAddr and not v4loAddr:
                raise ConfigError('IPv4 "remote-address" requires IPv4 "local-address"')

            v6remAddr = [addr for addr in openvpn['remote_address'] if is_ipv6(addr)]
            v6loAddr = [addr for addr in openvpn['local_address'] if is_ipv6(addr)]
            if v6loAddr and not v6remAddr:
                raise ConfigError('IPv6 "local-address" requires IPv6 "remote-address"')
            elif v6remAddr and not v6loAddr:
                raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"')

            if (v4loAddr == v4remAddr) or (v6remAddr == v4remAddr):
                raise ConfigError('"local-address" and "remote-address" cannot be the same')

            if dict_search('local_host', openvpn) in dict_search('local_address', openvpn):
                raise ConfigError('"local-address" cannot be the same as "local-host"')

            if dict_search('remote_host', openvpn) in dict_search('remote_address', openvpn):
                raise ConfigError('"remote-address" and "remote-host" can not be the same')

        if openvpn['device_type'] == 'tap':
            # we can only have one local_address, this is ensured above
            v4addr = None
            for laddr in openvpn['local_address']:
                if is_ipv4(laddr):
                    v4addr = laddr
                    break

            if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]:
                raise ConfigError('Must specify IPv4 "subnet-mask" for local-address')

        if dict_search('encryption.ncp_ciphers', openvpn):
            raise ConfigError('NCP ciphers can only be used in client or server mode')

    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')

        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')

        tmp = dict_search('server.subnet', openvpn)
        if tmp:
            v4_subnets = len([subnet for subnet in tmp if is_ipv4(subnet)])
            v6_subnets = len([subnet for subnet in tmp if is_ipv6(subnet)])
            if v4_subnets > 1:
                raise ConfigError('Cannot specify more than 1 IPv4 server subnet')
            if v6_subnets > 1:
                raise ConfigError('Cannot specify more than 1 IPv6 server subnet')

            if v6_subnets > 0 and v4_subnets == 0:
                raise ConfigError('IPv6 server requires an IPv4 server subnet')

            for subnet in tmp:
                if is_ipv4(subnet):
                    subnet = IPv4Network(subnet)

                    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')


        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')

        if dict_search('server.client_ip_pool', openvpn):
            if not (dict_search('server.client_ip_pool.start', openvpn) and 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.')

        for subnet in (dict_search('server.subnet', openvpn) or []):
            if is_ipv6(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' not 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')

        if file and not verify_diffie_hellman_length(file, 2048):
            raise ConfigError(f'Minimum DH key-size is 2048 bits')

        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 client config directory on demand
    if not os.path.exists(ccd_dir):
        os.makedirs(ccd_dir, 0o755)
        chown(ccd_dir, 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 'authentication' in openvpn:
        render(openvpn['auth_user_pass_file'], 'openvpn/auth.pw.tmpl', openvpn,
               user=user, group=group, permission=0o600)
    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['server_subnet'] = dict_search('server.subnet', openvpn)

            render(client_file, 'openvpn/client.conf.tmpl', client_config,
                   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,
           formater=lambda _: _.replace("&quot;", '"'), 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)