#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 LEAP
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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/>.
#
"""
This is a privileged helper script for safely running certain commands as root.
It should only be called by the Bitmask application.

USAGE:
  bitmask-root firewall stop
  bitmask-root firewall start GATEWAY1 GATEWAY2 ...
  bitmask-root openvpn stop
  bitmask-root openvpn start CONFIG1 CONFIG1 ...

All actions return exit code 0 for success, non-zero otherwise.

The `openvpn start` action is special: it calls exec on openvpn and replaces
the current process.
"""
# TODO should be tested with python3, which can be the default on some distro.

from __future__ import print_function
import os
import re
import subprocess
import socket
import sys
import traceback


# XXX not standard
import psutil

cmdcheck = subprocess.check_output

##
## CONSTANTS
##

SCRIPT = "bitmask-root"
NAMESERVER = "10.42.0.1"
BITMASK_CHAIN = "bitmask"

IP = "/bin/ip"
IPTABLES = "/sbin/iptables"
IP6TABLES = "/sbin/ip6tables"
RESOLVCONF = "/sbin/resolvconf"
OPENVPN_USER = "nobody"
OPENVPN_GROUP = "nogroup"

LEAPOPENVPN = "LEAPOPENVPN"
OPENVPN_SYSTEM_BIN = "/usr/sbin/openvpn"  # Debian location
OPENVPN_LEAP_BIN = "/usr/sbin/leap-openvpn"  # installed by bundle


"""
The path to the script to update resolv.conf
"""
# XXX We have to check if we have a valid resolvconf, and use
# the old resolv-update if not.
LEAP_UPDATE_RESOLVCONF_FILE = "/etc/leap/update-resolv-conf"
LEAP_RESOLV_UPDATE = "/etc/leap/resolv-update"

FIXED_FLAGS = [
    "--setenv", "LEAPOPENVPN", "1",
    "--nobind",
    "--client",
    "--dev", "tun",
    "--tls-client",
    "--remote-cert-tls", "server",
    "--management-signal",
    "--management", MANAGEMENT_SOCKET, "unix",
    "--script-security", "1"
    "--user", "nobody",
    "--group", "nogroup",
]

#    "--management", MANAGEMENT_SOCKET, "unix",

ALLOWED_FLAGS = {
    "--remote": ["IP", "NUMBER", "PROTO"],
    "--tls-cipher": ["CIPHER"],
    "--cipher": ["CIPHER"],
    "--auth": ["CIPHER"],
    "--management": ["DIR", "UNIXSOCKET"],
    "--management-client-user": ["USER"],
    "--cert": ["FILE"],
    "--key": ["FILE"],
    "--ca": ["FILE"]
}

PARAM_FORMATS = {
    "NUMBER": lambda s: re.match("^\d+$", s),
    "PROTO": lambda s: re.match("^(tcp|udp)$", s),
    "IP": lambda s: is_valid_address(s),
    "CIPHER": lambda s: re.match("^[A-Z0-9-]+$", s),
    "USER": lambda s: re.match(
        "^[a-zA-Z0-9_\.\@][a-zA-Z0-9_\-\.\@]*\$?$", s),  # IEEE Std 1003.1-2001
    "FILE": lambda s: os.path.isfile(s),
    "DIR": lambda s: os.path.isdir(os.path.split(s)[0]),
    "UNIXSOCKET": lambda s: s == "unix"
}


DEBUG = os.getenv("DEBUG")
TEST = os.getenv("TEST")

if DEBUG:
    import logging
    formatter = logging.Formatter(
        "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
    ch = logging.StreamHandler()
    ch.setLevel(logging.DEBUG)
    ch.setFormatter(formatter)
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.DEBUG)
    logger.addHandler(ch)

##
## UTILITY
##


def is_valid_address(value):
    """
    Validate that the passed ip is a valid IP address.

    :param value: the value to be validated
    :type value: str
    :rtype: bool
    """
    try:
        socket.inet_aton(value)
        return True
    except Exception:
        print("%s: ERROR: MALFORMED IP: %s!" % (SCRIPT, value))
        return False


def has_system_resolvconf():
    """
    Return True if resolvconf is found in the system.

    :rtype: bool
    """
    return os.path.isfile(RESOLVCONF)


def has_valid_update_resolvconf():
    """
    Return True if a valid update-resolv-conf script is found in the system.

    :rtype: bool
    """
    return os.path.isfile(LEAP_UPDATE_RESOLVCONF_FILE)


def has_valid_leap_resolv_update():
    """
    Return True if a valid resolv-update script is found in the system.

    :rtype: bool
    """
    return os.path.isfile(LEAP_RESOLV_UPDATE)


def split_list(_list, regex):
    """
    Split a list based on a regex:
    e.g. split_list(["xx", "yy", "x1", "zz"], "^x") => [["xx", "yy"], ["x1",
    "zz"]]

    :param _list: the list to be split.
    :type _list: list
    :param regex: the regex expression to filter with.
    :type regex: str

    :rtype: list
    """
    if not hasattr(regex, "match"):
        regex = re.compile(regex)
    result = []
    i = 0
    if not _list:
        return result
    while True:
        if regex.match(_list[i]):
            result.append([])
            while True:
                result[-1].append(_list[i])
                i += 1
                if i >= len(_list) or regex.match(_list[i]):
                    break
        else:
            i += 1
        if i >= len(_list):
            break
    return result


def run(command, *args, **options):
    """
    Run an external command.

    Options:

      `check`: If True, check the command's output. bail if non-zero. (the
               default is true unless detach or input is true)
      `exitcode`: like `check`, but return exitcode instead of bailing.
      `detach`: If True, run in detached process.
      `input`: If True, open command for writing stream to, returning the Popen
               object.
    """
    parts = [command]
    parts.extend(args)
    if TEST or DEBUG:
        print("%s run: %s " (SCRIPT, " ".join(parts)))

    _check = options.get("check", True)
    _detach = options.get("detach", False)
    _input = options.get("input", False)
    _exitcode = options.get("exitcode", False)

    if not _check or _detach or _input:
        if _input:
            return subprocess.Popen(parts, stdin=subprocess.PIPE)
        else:
            # XXX ok with return None ??
            subprocess.Popen(parts)
    else:
        try:
            devnull = open('/dev/null', 'w')
            subprocess.check_call(parts, stdout=devnull, stderr=devnull)
            return 0
        except subprocess.CalledProcessError as exc:
            if DEBUG:
                logger.exception(exc)
            if _exitcode:
                return exc.returncode
            else:
                bail("ERROR: Could not run %s: %s" % (exc.cmd, exc.output),
                     exception=exc)


def bail(msg=None, exception=None):
    """
    Abnormal exit.

    :param msg: optional error message.
    :type msg: str
    """
    if msg is not None:
        print("%s: %s" % (SCRIPT, msg))
    if exception is not None:
        traceback.print_exc()
    exit(1)

##
## OPENVPN
##


def get_openvpn_bin():
    """
    Return the path for either the system openvpn or the one the
    bundle has put there.
    """
    if os.path.isfile(OPENVPN_SYSTEM_BIN):
        return OPENVPN_SYSTEM_BIN

    # the bundle option should be removed from the debian package.
    if os.path.isfile(OPENVPN_LEAP_BIN):
        return OPENVPN_LEAP_BIN


def parse_openvpn_flags(args):
    """
    Take argument list from the command line and parse it, only allowing some
    configuration flags.

    :type args: list
    """
    result = []
    try:
        for flag in split_list(args, "^--"):
            flag_name = flag[0]
            if flag_name in ALLOWED_FLAGS:
                result.append(flag_name)
                required_params = ALLOWED_FLAGS[flag_name]
                if required_params:
                    flag_params = flag[1:]
                    if len(flag_params) != len(required_params):
                        print("%s: ERROR: not enough params for %s" %
                              (SCRIPT, flag_name))
                        return None
                    for param, param_type in zip(flag_params, required_params):
                        if PARAM_FORMATS[param_type](param):
                            result.append(param)
                        else:
                            print("%s: ERROR: Bad argument %s" %
                                  (SCRIPT, param))
                            return None
            else:
                print("WARNING: unrecognized openvpn flag %s" % flag_name)
        return result
    except Exception as exc:
        print("%s: ERROR PARSING FLAGS: %s" % (SCRIPT, exc))
        if DEBUG:
            logger.exception(exc)
        return None


def openvpn_start(args):
    """
    Launch openvpn, sanitizing input, and replacing the current process with
    the openvpn process.

    :param args: arguments to be passed to openvpn
    :type args: list
    """
    openvpn_flags = parse_openvpn_flags(args)
    if openvpn_flags:
        OPENVPN = get_openvpn_bin()
        flags = [OPENVPN] + FIXED_FLAGS + openvpn_flags
        if DEBUG:
            print("%s: running openvpn with flags:" % (SCRIPT,))
            print(flags)
        # note: first argument to command is ignored, but customarily set to
        # the command.
        os.execv(OPENVPN, flags)
    else:
        bail('ERROR: could not parse openvpn options')


def openvpn_stop(args):
    """
    Stop openvpn.

    :param args: arguments to openvpn
    :type args: list
    """
    # XXX this deps on psutil, which is not there in the bundle
    # case. We could try to manually parse proc system.
    for proc in psutil.process_iter():
        if LEAPOPENVPN in proc.cmdline:
            # FIXME naive approach. this will kill try to kill *anythin*, we
            # should check that the command is openvpn. -- kali
            proc.terminate()

##
## DNS
##


def set_dns_nameserver(ip_address):
    """
    Add the tunnel DNS server to `resolv.conf`

    :param ip_address: the ip to add to `resolv.conf`
    :type ip_address: str
    """
    if os.path.isfile(RESOLVCONF):
        process = run(RESOLVCONF, "-a", "bitmask", input=True)
        process.communicate("nameserver %s\n" % ip_address)
    else:
        bail("ERROR: package openresolv or resolvconf not installed.")


def restore_dns_nameserver():
    """
    Remove tunnel DNS server from `resolv.conf`
    """
    if os.path.isfile(RESOLVCONF):
        run(RESOLVCONF, "-d", "bitmask")
    else:
        print("%s: ERROR: package openresolv or resolvconf not installed." %
              (SCRIPT,))


##
## FIREWALL
##


def get_gateways(gateways):
    """
    Filter a passed sequence of gateways, returning only the valid ones.

    :param gateways: a sequence of gateways to filter.
    :type gateways: iterable
    :rtype: iterable
    """
    result = filter(is_valid_address, gateways)
    if not result:
        bail("ERROR: No valid gateways specified")
    else:
        return result


def get_default_device():
    """
    Retrieve the current default network device.

    :rtype: str
    """
    routes = subprocess.check_output([IP, "route", "show"])
    match = re.search("^default .*dev ([^\s]*) .*$", routes, flags=re.M)
    if match.groups():
        return match.group(1)
    else:
        bail("Could not find default device")


def get_local_network_ipv4(device):
    """
    Get the local ipv4 addres for a given device.

    :param device:
    :type device: str
    """
    addresses = cmdcheck([IP, "-o", "address", "show", "dev", device])
    match = re.search("^.*inet ([^ ]*) .*$", addresses, flags=re.M)
    if match.groups():
        return match.group(1)
    else:
        return None


def get_local_network_ipv6(device):
    """
    Get the local ipv6 addres for a given device.

    :param device:
    :type device: str
    """
    addresses = cmdcheck([IP, "-o", "address", "show", "dev", device])
    match = re.search("^.*inet6 ([^ ]*) .*$", addresses, flags=re.M)
    if match.groups():
        return match.group(1)
    else:
        return None


def run_iptable_with_check(cmd, *args, **options):
    """
    Run an iptables command checking to see if it should:
      for --insert: run only if rule does not already exist.
      for --delete: run only if rule does exist.
    other commands are run normally.
    """
    if "--insert" in args:
        check_args = [arg.replace("--insert", "--check") for arg in args]
        check_code = run(cmd, *check_args, exitcode=True)
        if check_code != 0:
            run(cmd, *args, **options)
    elif "--delete" in args:
        check_args = [arg.replace("--delete", "--check") for arg in args]
        check_code = run(cmd, *check_args, exitcode=True)
        if check_code == 0:
            run(cmd, *args, **options)
    else:
        run(cmd, *args, **options)


def iptables(*args, **options):
    """
    Run iptables4 and iptables6.
    """
    ip4tables(*args, **options)
    ip6tables(*args, **options)


def ip4tables(*args, **options):
    """
    Run iptables4 with checks.
    """
    run_iptable_with_check(IPTABLES, *args, **options)


def ip6tables(*args, **options):
    """
    Run iptables6 with checks.
    """
    run_iptable_with_check(IP6TABLES, *args, **options)


def ipv4_chain_exists(table):
    """
    Check if a given chain exists.

    :param table: the table to check against
    :type table: str
    :rtype: bool
    """
    code = run(IPTABLES, "--list", table, "--numeric", exitcode=True)
    return code == 0


def ipv6_chain_exists(table):
    """
    Check if a given chain exists.

    :param table: the table to check against
    :type table: str
    :rtype: bool
    """
    code = run(IP6TABLES, "--list", table, "--numeric", exitcode=True)
    return code == 0


def firewall_start(args):
    """
    Bring up the firewall.

    :param args: list of gateways, to be sanitized.
    :type args: list
    """
    default_device = get_default_device()
    local_network_ipv4 = get_local_network_ipv4(default_device)
    local_network_ipv6 = get_local_network_ipv6(default_device)
    gateways = get_gateways(args)

    # add custom chain "bitmask"
    if not ipv4_chain_exists(BITMASK_CHAIN):
        ip4tables("--new-chain", BITMASK_CHAIN)
    if not ipv6_chain_exists(BITMASK_CHAIN):
        ip6tables("--new-chain", BITMASK_CHAIN)
    iptables("--insert", "OUTPUT", "--jump", BITMASK_CHAIN)

    # reject everything
    iptables("--insert", BITMASK_CHAIN, "-o", default_device,
             "--jump", "REJECT")

    # allow traffic to gateways
    for gateway in gateways:
        ip4tables("--insert", BITMASK_CHAIN, "--destination", gateway,
                  "-o", default_device, "--jump", "ACCEPT")

    # allow traffic to IPs on local network
    if local_network_ipv4:
        ip4tables("--insert", BITMASK_CHAIN,
                  "--destination", local_network_ipv4, "-o", default_device,
                  "--jump", "ACCEPT")
    if local_network_ipv6:
        ip6tables("--insert", BITMASK_CHAIN,
                  "--destination", local_network_ipv6, "-o", default_device,
                  "--jump", "ACCEPT")

    # block DNS requests to anyone but the service provider or localhost
    ip4tables("--insert", BITMASK_CHAIN, "--protocol", "udp", "--dport", "53",
              "--jump", "REJECT")
    for allowed_dns in gateways + ["127.0.0.1", "127.0.1.1"]:
        ip4tables("--insert", "bitmask", "--protocol", "udp", "--dport", "53",
                  "--destination", allowed_dns, "--jump", "ACCEPT")


def firewall_stop():
    """
    Stop the firewall.
    """
    iptables("--delete", "OUTPUT", "--jump", BITMASK_CHAIN)
    if ipv4_chain_exists(BITMASK_CHAIN):
        ip4tables("--flush", BITMASK_CHAIN)
        ip4tables("--delete-chain", BITMASK_CHAIN)
    if ipv6_chain_exists(BITMASK_CHAIN):
        ip6tables("--flush", BITMASK_CHAIN)
        ip6tables("--delete-chain", BITMASK_CHAIN)

##
## MAIN
##


def main():
    if len(sys.argv) >= 3:
        command = "_".join(sys.argv[1:3])
        args = sys.argv[3:]

        if command == "openvpn_start":
            openvpn_start(args)

        elif command == "openvpn_stop":
            openvpn_stop(args)

        elif command == "firewall_start":
            try:
                firewall_start(args)
                set_dns_nameserver(NAMESERVER)
            except Exception as ex:
                restore_dns_nameserver()
                firewall_stop()
                bail("ERROR: could not start firewall", ex)

        elif command == "firewall_stop":
            try:
                restore_dns_nameserver()
                firewall_stop()
            except Exception as ex:
                bail("ERROR: could not stop firewall", ex)

        else:
            bail("ERROR: No such command")
    else:
        bail("ERROR: No such command")

if __name__ == "__main__":
    if DEBUG:
        logger.debug(" ".join(sys.argv))
    main()
    print("%s: done" % (SCRIPT,))
    exit(0)