diff options
| -rw-r--r-- | src/leap/bitmask/core/configurable.py | 2 | ||||
| -rw-r--r-- | src/leap/bitmask/core/service.py | 2 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/__init__.py | 10 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/constants.py | 28 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/eip.py | 87 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/errors.py | 14 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/fw/__init__.py | 0 | ||||
| -rwxr-xr-x | src/leap/bitmask/vpn/fw/bitmask-root | 971 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/fw/firewall.py | 95 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/launcher.py | 379 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/launchers/__init__.py | 0 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/launchers/darwin.py | 199 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/launchers/linux.py | 181 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/launchers/windows.py | 73 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/manager.py | 160 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/privilege.py | 210 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/process.py | 920 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/service.py | 100 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/statusqueue.py | 42 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/udstelnet.py | 60 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/utils.py | 104 | 
21 files changed, 3635 insertions, 2 deletions
| diff --git a/src/leap/bitmask/core/configurable.py b/src/leap/bitmask/core/configurable.py index 51a100d6..692ec066 100644 --- a/src/leap/bitmask/core/configurable.py +++ b/src/leap/bitmask/core/configurable.py @@ -46,7 +46,7 @@ class ConfigurableService(service.MultiService):  DEFAULT_CONFIG = """  [services]  mail = True -eip = True +eip = False  zmq = True  web = True  onion = False diff --git a/src/leap/bitmask/core/service.py b/src/leap/bitmask/core/service.py index c3e97f72..a34bf0e9 100644 --- a/src/leap/bitmask/core/service.py +++ b/src/leap/bitmask/core/service.py @@ -32,8 +32,8 @@ from leap.bitmask.core import _zmq  from leap.bitmask.core import flags  from leap.bitmask.core import _session  from leap.bitmask.core.web.service import HTTPDispatcherService +from leap.bitmask.vpn import EIPService  from leap.common.events import server as event_server -# from leap.vpn import EIPService  logger = Logger() diff --git a/src/leap/bitmask/vpn/__init__.py b/src/leap/bitmask/vpn/__init__.py new file mode 100644 index 00000000..6c3cf064 --- /dev/null +++ b/src/leap/bitmask/vpn/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from .manager import VPNManager +from .eip import EIPManager +from .service import EIPService +from .fw.firewall import FirewallManager + +import errors + +__all__ = ['VPNManager', 'FirewallManager', 'EIPManager', 'EIPService', +           'errors'] diff --git a/src/leap/bitmask/vpn/constants.py b/src/leap/bitmask/vpn/constants.py new file mode 100644 index 00000000..c7a5147b --- /dev/null +++ b/src/leap/bitmask/vpn/constants.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# constants.py +# Copyright (C) 2015 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/>. + +""" +System constants +""" +import platform + +_system = platform.system() + +IS_LINUX = _system == "Linux" +IS_MAC = _system == "Darwin" +IS_UNIX = IS_MAC or IS_LINUX +IS_WIN = _system == "Windows" diff --git a/src/leap/bitmask/vpn/eip.py b/src/leap/bitmask/vpn/eip.py new file mode 100644 index 00000000..cfd8b592 --- /dev/null +++ b/src/leap/bitmask/vpn/eip.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# cli.py +# Copyright (C) 2015 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/>. + +from colorama import Fore + +from leap.bitmask.vpn import VPNManager +from leap.bitmask.vpn.fw.firewall import FirewallManager +from leap.bitmask.vpn.statusqueue import StatusQueue +from leap.bitmask.vpn.zmq_pub import ZMQPublisher + + +class EIPManager(object): + +    def __init__(self, remotes, cert, key, ca, flags): + +        self._firewall = FirewallManager(remotes) +        self._status_queue = StatusQueue() +        self._pub = ZMQPublisher(self._status_queue) +        self._vpn = VPNManager(remotes, cert, key, ca, flags, +                               self._status_queue) + +    def start(self): +        """ +        Start EIP service (firewall and vpn) + +        This may raise exceptions, see errors.py +        """ +        # self._pub.start() +        print(Fore.BLUE + "Firewall: starting..." + Fore.RESET) +        fw_ok = self._firewall.start() +        if not fw_ok: +            return False + +        print(Fore.GREEN + "Firewall: started" + Fore.RESET) + +        vpn_ok = self._vpn.start() +        if not vpn_ok: +            print (Fore.RED + "VPN: Error starting." + Fore.RESET) +            self._firewall.stop() +            print(Fore.GREEN + "Firewall: stopped." + Fore.RESET) +            return False + +        print(Fore.GREEN + "VPN: started" + Fore.RESET) + +    def stop(self): +        """ +        Stop EIP service +        """ +        # self._pub.stop() +        print(Fore.BLUE + "Firewall: stopping..." + Fore.RESET) +        fw_ok = self._firewall.stop() + +        if not fw_ok: +            print (Fore.RED + "Firewall: Error stopping." + Fore.RESET) +            return False + +        print(Fore.GREEN + "Firewall: stopped." + Fore.RESET) +        print(Fore.BLUE + "VPN: stopping..." + Fore.RESET) + +        vpn_ok = self._vpn.stop() +        if not vpn_ok: +            print (Fore.RED + "VPN: Error stopping." + Fore.RESET) +            return False + +        print(Fore.GREEN + "VPN: stopped." + Fore.RESET) +        return True + +    def get_state(self): +        pass + +    def get_status(self): +        pass diff --git a/src/leap/bitmask/vpn/errors.py b/src/leap/bitmask/vpn/errors.py new file mode 100644 index 00000000..77cf1dcb --- /dev/null +++ b/src/leap/bitmask/vpn/errors.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from .process import OpenVPNAlreadyRunning, AlienOpenVPNAlreadyRunning +from .launcher import OpenVPNNotFoundException, VPNLauncherException +from leap.bitmask.vpn.launchers.linux import ( +    EIPNoPolkitAuthAgentAvailable, EIPNoPkexecAvailable) +from leap.bitmask.vpn.launchers.darwin import EIPNoTunKextLoaded + + +__all__ = ["OpenVPNAlreadyRunning", "AlienOpenVPNAlreadyRunning", +           "OpenVPNNotFoundException", "VPNLauncherException", +           "EIPNoPolkitAuthAgentAvailable", "EIPNoPkexecAvailable", +           "EIPNoTunKextLoaded"] diff --git a/src/leap/bitmask/vpn/fw/__init__.py b/src/leap/bitmask/vpn/fw/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/leap/bitmask/vpn/fw/__init__.py diff --git a/src/leap/bitmask/vpn/fw/bitmask-root b/src/leap/bitmask/vpn/fw/bitmask-root new file mode 100755 index 00000000..80ac12e8 --- /dev/null +++ b/src/leap/bitmask/vpn/fw/bitmask-root @@ -0,0 +1,971 @@ +#!/usr/bin/python2.7 +# -*- 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 [restart] GATEWAY1 GATEWAY2 ... +  bitmask-root openvpn stop +  bitmask-root openvpn start CONFIG1 CONFIG1 ... +  bitmask-root fw-email stop +  bitmask-root fw-email start uid + +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. If the `restart` parameter is passed, the firewall will +not be teared down in the case of an error during launch. +""" +# TODO should be tested with python3, which can be the default on some distro. +from __future__ import print_function +import os +import re +import signal +import socket +import syslog +import subprocess +import sys +import traceback + +cmdcheck = subprocess.check_output + +# +# CONSTANTS +# + + +def get_no_group_name(): +    """ +    Return the right group name to use for the current OS. +    Examples: +        - Ubuntu: nogroup +        - Arch: nobody + +    :rtype: str or None +    """ +    import grp +    try: +        grp.getgrnam('nobody') +        return 'nobody' +    except KeyError: +        try: +            grp.getgrnam('nogroup') +            return 'nogroup' +        except KeyError: +            return None + + +VERSION = "6" +SCRIPT = "bitmask-root" +NAMESERVER = "10.42.0.1" +BITMASK_CHAIN = "bitmask" +BITMASK_CHAIN_NAT_OUT = "bitmask" +BITMASK_CHAIN_NAT_POST = "bitmask_postrouting" +BITMASK_CHAIN_EMAIL = "bitmask_email" +BITMASK_CHAIN_EMAIL_OUT = "bitmask_email_output" +LOCAL_INTERFACE = "lo" +IMAP_PORT = "1984" +SMTP_PORT = "2013" + +IP = "/sbin/ip" +IPTABLES = "/sbin/iptables" +IP6TABLES = "/sbin/ip6tables" + +OPENVPN_USER = "nobody" +OPENVPN_GROUP = get_no_group_name() +LEAPOPENVPN = "LEAPOPENVPN" +OPENVPN_SYSTEM_BIN = "/usr/sbin/openvpn"  # Debian location +OPENVPN_LEAP_BIN = "/usr/local/sbin/leap-openvpn"  # installed by bundle + +FIXED_FLAGS = [ +    "--setenv", "LEAPOPENVPN", "1", +    "--nobind", +    "--client", +    "--dev", "tun", +    "--tls-client", +    "--remote-cert-tls", "server", +    "--management-signal", +    "--script-security", "1", +    "--user", "nobody", +    "--remap-usr1", "SIGTERM", +] + +if OPENVPN_GROUP is not None: +    FIXED_FLAGS.extend(["--group", OPENVPN_GROUP]) + +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"], +    "--fragment": ["NUMBER"] +} + +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", +    "UID": lambda s: re.match("^[a-zA-Z0-9]+$", s) +} + + +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) + +syslog.openlog(SCRIPT) + +# +# 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: +        log("%s: ERROR: MALFORMED IP: %s!" % (SCRIPT, value)) +        return False + + +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 get_process_list(): +    """ +    Get a process list by reading `/proc` filesystem. + +    :return: a list of tuples, each containing pid and command string. +    :rtype: tuple if lists +    """ +    res = [] +    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()] + +    for pid in pids: +        try: +            res.append((pid, open( +                os.path.join( +                    '/proc', pid, 'cmdline'), 'rb').read())) +        except IOError:  # proc has already terminated +            continue +    return filter(None, res) + + +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. +      `throw`: If True, raise an exception if there is an error instead +               of bailing. +    """ +    parts = [command] +    parts.extend(args) +    debug("%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) +    _throw = options.get("throw", False) + +    if not (_check or _throw) or _detach or _input: +        if _input: +            return subprocess.Popen(parts, stdin=subprocess.PIPE) +        else: +            subprocess.Popen(parts) +            return None +    else: +        try: +            devnull = open('/dev/null', 'w') +            subprocess.check_call(parts, stdout=devnull, stderr=devnull) +            return 0 +        except subprocess.CalledProcessError as exc: +            if _exitcode: +                if exc.returncode != 1: +                    # 0 or 1 is to be expected, but anything else +                    # should be logged. +                    debug("ERROR: Could not run %s: %s" % +                          (exc.cmd, exc.output), exception=exc) +                return exc.returncode +            elif _throw: +                raise exc +            else: +                bail("ERROR: Could not run %s: %s" % (exc.cmd, exc.output), +                     exception=exc) + + +def log(msg=None, exception=None, priority=syslog.LOG_INFO): +    """ +    print and log warning message or exception. + +    :param msg: optional error message. +    :type msg: str +    :param msg: optional exception. +    :type msg: Exception +    :param msg: syslog level +    :type msg: one of LOG_EMERG, LOG_ALERT, LOG_CRIT, LOG_ERR, +               LOG_WARNING, LOG_NOTICE, LOG_INFO, LOG_DEBUG +    """ +    if msg is not None: +        print("%s: %s" % (SCRIPT, msg)) +        syslog.syslog(priority, msg) +    if exception is not None: +        if TEST or DEBUG: +            traceback.print_exc() +        syslog.syslog(priority, traceback.format_exc()) + + +def debug(msg=None, exception=None): +    """ +    Just like log, but is skipped unless DEBUG. Use syslog.LOG_INFO +    even for debug messages (we don't want to miss them). +    """ +    if TEST or DEBUG: +        log(msg, exception) + + +def bail(msg=None, exception=None): +    """ +    abnormal exit. like log(), but exits with error status code. +    """ +    log(msg, exception) +    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): +                        log("%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: +                            log("%s: ERROR: Bad argument %s" % +                                (SCRIPT, param)) +                            return None +            else: +                log("WARNING: unrecognized openvpn flag %s" % flag_name) +        return result +    except Exception as exc: +        log("%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: +            log("%s: running openvpn with flags:" % (SCRIPT,)) +            log(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 the openvpn that has likely been launched by bitmask. + +    :param args: arguments to openvpn +    :type args: list +    """ +    plist = get_process_list() +    OPENVPN_BIN = get_openvpn_bin() +    found_leap_openvpn = filter( +        lambda (p, s): s.startswith(OPENVPN_BIN) and LEAPOPENVPN in s, +        plist) + +    if found_leap_openvpn: +        pid = found_leap_openvpn[0][0] +        os.kill(int(pid), signal.SIGTERM) + +# +# 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 and 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 and 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 and 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 --append: run only if rule does not already exist. +      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 "--append" in args: +        check_args = [arg.replace("--append", "--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) + +# +# NOTE: these tests to see if a chain exists might incorrectly return false. +# This happens when there is an error in calling `iptables --list bitmask`. +# +# For this reason, when stopping the firewall, we do not trust the +# output of ipvx_chain_exists() but instead always attempt to delete +# the chain. +# + + +def ipv4_chain_exists(chain, table=None): +    """ +    Check if a given chain exists. Only returns true if it actually exists, +    but might return false if it exists and iptables failed to run. + +    :param chain: the chain to check against +    :type chain: str +    :rtype: bool +    """ +    if table is not None: +        code = run(IPTABLES, "-t", table, +                   "--list", chain, "--numeric", exitcode=True) +    else: +        code = run(IPTABLES, "--list", chain, "--numeric", exitcode=True) +    if code == 0: +        return True +    elif code == 1: +        return False +    else: +        log("ERROR: Could not determine state of iptable chain") +        return False + + +def ipv6_chain_exists(chain): +    """ +    see ipv4_chain_exists() + +    :param chain: the chain to check against +    :type chain: str +    :rtype: bool +    """ +    code = run(IP6TABLES, "--list", chain, "--numeric", exitcode=True) +    if code == 0: +        return True +    elif code == 1: +        return False +    else: +        log("ERROR: Could not determine state of iptable chain") +        return False + + +def enable_ip_forwarding(): +    """ +    ip_fowarding must be enabled for the firewall to work. +    """ +    with open('/proc/sys/net/ipv4/ip_forward', 'w') as f: +        f.write('1\n') + + +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" to front of OUTPUT chain for both +    # the 'filter' and the 'nat' tables. +    if not ipv4_chain_exists(BITMASK_CHAIN): +        ip4tables("--new-chain", BITMASK_CHAIN) +    if not ipv4_chain_exists(BITMASK_CHAIN_NAT_OUT, 'nat'): +        ip4tables("--table", "nat", "--new-chain", BITMASK_CHAIN_NAT_OUT) +    if not ipv4_chain_exists(BITMASK_CHAIN_NAT_POST, 'nat'): +        ip4tables("--table", "nat", "--new-chain", BITMASK_CHAIN_NAT_POST) +    if not ipv6_chain_exists(BITMASK_CHAIN): +        ip6tables("--new-chain", BITMASK_CHAIN) +    ip4tables("--table", "nat", "--insert", "OUTPUT", +              "--jump", BITMASK_CHAIN_NAT_OUT) +    ip4tables("--table", "nat", "--insert", "POSTROUTING", +              "--jump", BITMASK_CHAIN_NAT_POST) +    iptables("--insert", "OUTPUT", "--jump", BITMASK_CHAIN) + +    # route all ipv4 DNS over VPN +    # (note: NAT does not work with ipv6 until kernel 3.7) +    enable_ip_forwarding() +    # allow dns to localhost +    ip4tables("-t", "nat", "--append", BITMASK_CHAIN, "--protocol", "udp", +              "--dest", "127.0.1.1,127.0.0.1", "--dport", "53", +              "--jump", "ACCEPT") +    # rewrite all outgoing packets to use VPN DNS server +    # (DNS does sometimes use TCP!) +    ip4tables("-t", "nat", "--append", BITMASK_CHAIN_NAT_OUT, "-p", "udp", +              "--dport", "53", "--jump", "DNAT", "--to", NAMESERVER+":53") +    ip4tables("-t", "nat", "--append", BITMASK_CHAIN_NAT_OUT, "-p", "tcp", +              "--dport", "53", "--jump", "DNAT", "--to", NAMESERVER+":53") +    # enable masquerading, so that DNS packets rewritten by DNAT will +    # have the correct source IPs +    ip4tables("-t", "nat", "--append", BITMASK_CHAIN_NAT_POST, +              "--protocol", "udp", "--dport", "53", "--jump", "MASQUERADE") +    ip4tables("-t", "nat", "--append", BITMASK_CHAIN_NAT_POST, +              "--protocol", "tcp", "--dport", "53", "--jump", "MASQUERADE") + +    # allow local network traffic +    if local_network_ipv4: +        # allow local network destinations +        ip4tables("--append", BITMASK_CHAIN, +                  "--destination", local_network_ipv4, "-o", default_device, +                  "--jump", "ACCEPT") +        # allow local network sources for DNS +        # (required to allow local network DNS that gets rewritten by NAT +        #  to get passed through so that MASQUERADE can set correct source IP) +        ip4tables("--append", BITMASK_CHAIN, +                  "--source", local_network_ipv4, "-o", default_device, +                  "-p", "udp", "--dport", "53", "--jump", "ACCEPT") +        ip4tables("--append", BITMASK_CHAIN, +                  "--source", local_network_ipv4, "-o", default_device, +                  "-p", "tcp", "--dport", "53", "--jump", "ACCEPT") +        # allow multicast Simple Service Discovery Protocol +        ip4tables("--append", BITMASK_CHAIN, +                  "--protocol", "udp", +                  "--destination", "239.255.255.250", "--dport", "1900", +                  "-o", default_device, "--jump", "RETURN") +        # allow multicast Bonjour/mDNS +        ip4tables("--append", BITMASK_CHAIN, +                  "--protocol", "udp", +                  "--destination", "224.0.0.251", "--dport", "5353", +                  "-o", default_device, "--jump", "RETURN") +    if local_network_ipv6: +        ip6tables("--append", BITMASK_CHAIN, +                  "--destination", local_network_ipv6, "-o", default_device, +                  "--jump", "ACCEPT") +        # allow multicast Simple Service Discovery Protocol +        ip6tables("--append", BITMASK_CHAIN, +                  "--protocol", "udp", +                  "--destination", "FF05::C", "--dport", "1900", +                  "-o", default_device, "--jump", "RETURN") +        # allow multicast Bonjour/mDNS +        ip6tables("--append", BITMASK_CHAIN, +                  "--protocol", "udp", +                  "--destination", "FF02::FB", "--dport", "5353", +                  "-o", default_device, "--jump", "RETURN") + +    # allow ipv4 traffic to gateways +    for gateway in gateways: +        ip4tables("--append", BITMASK_CHAIN, "--destination", gateway, +                  "-o", default_device, "--jump", "ACCEPT") + +    # log rejected packets to syslog +    if DEBUG: +        iptables("--append", BITMASK_CHAIN, "-o", default_device, +                 "--jump", "LOG", "--log-prefix", "iptables denied: ", +                 "--log-level", "7") + +    # for now, ensure all other ipv6 packets get rejected (regardless of +    # device). not sure why, but "-p any" doesn't work. +    ip6tables("--append", BITMASK_CHAIN, "-p", "tcp", "--jump", "REJECT") +    ip6tables("--append", BITMASK_CHAIN, "-p", "udp", "--jump", "REJECT") + +    # reject all other ipv4 sent over the default device +    ip4tables("--append", BITMASK_CHAIN, "-o", +              default_device, "--jump", "REJECT") + + +def firewall_stop(): +    """ +    Stop the firewall. Because we really really always want the firewall to +    be stopped if at all possible, this function is cautious and contains a +    lot of trys and excepts. + +    If there were any problems, we raise an exception at the end. This allows +    the calling code to retry stopping the firewall. Stopping the firewall +    can fail if iptables is being run by another process (only one iptables +    command can be run at a time). +    """ +    ok = True + +    # -t filter -D OUTPUT -j bitmask +    try: +        iptables("--delete", "OUTPUT", "--jump", BITMASK_CHAIN, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to remove bitmask firewall from OUTPUT chain " +              "(maybe it is already removed?)", exc) +        ok = False + +    # -t nat -D OUTPUT -j bitmask +    try: +        ip4tables("-t", "nat", "--delete", "OUTPUT", +                  "--jump", BITMASK_CHAIN_NAT_OUT, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to remove bitmask firewall from OUTPUT chain " +              "in 'nat' table (maybe it is already removed?)", exc) +        ok = False + +    # -t nat -D POSTROUTING -j bitmask_postrouting +    try: +        ip4tables("-t", "nat", "--delete", "POSTROUTING", +                  "--jump", BITMASK_CHAIN_NAT_POST, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to remove bitmask firewall from POSTROUTING " +              "chain in 'nat' table (maybe it is already removed?)", exc) +        ok = False + +    # -t filter --delete-chain bitmask +    try: +        ip4tables("--flush", BITMASK_CHAIN, throw=True) +        ip4tables("--delete-chain", BITMASK_CHAIN, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv4 firewall " +              "chain (maybe it is already destroyed?)", exc) +        ok = False + +    # -t nat --delete-chain bitmask +    try: +        ip4tables("-t", "nat", "--flush", BITMASK_CHAIN_NAT_OUT, throw=True) +        ip4tables("-t", "nat", "--delete-chain", +                  BITMASK_CHAIN_NAT_OUT, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv4 firewall " +              "chain in 'nat' table (maybe it is already destroyed?)", exc) +        ok = False + +    # -t nat --delete-chain bitmask_postrouting +    try: +        ip4tables("-t", "nat", "--flush", BITMASK_CHAIN_NAT_POST, throw=True) +        ip4tables("-t", "nat", "--delete-chain", +                  BITMASK_CHAIN_NAT_POST, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv4 firewall " +              "chain in 'nat' table (maybe it is already destroyed?)", exc) +        ok = False + +    # -t filter --delete-chain bitmask (ipv6) +    try: +        ip6tables("--flush", BITMASK_CHAIN, throw=True) +        ip6tables("--delete-chain", BITMASK_CHAIN, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv6 firewall " +              "chain (maybe it is already destroyed?)", exc) +        ok = False + +    if not (ok or ipv4_chain_exists or ipv6_chain_exists): +        raise Exception("firewall might still be left up. " +                        "Please try `firewall stop` again.") + + +def fw_email_start(args): +    """ +    Bring up the email firewall. + +    :param args: the user uid of the bitmask process +    :type args: list +    """ +    # add custom chain "bitmask_email" to front of INPUT chain +    if not ipv4_chain_exists(BITMASK_CHAIN_EMAIL): +        ip4tables("--new-chain", BITMASK_CHAIN_EMAIL) +    if not ipv6_chain_exists(BITMASK_CHAIN_EMAIL): +        ip6tables("--new-chain", BITMASK_CHAIN_EMAIL) +    iptables("--insert", "INPUT", "--jump", BITMASK_CHAIN_EMAIL) + +    # add custom chain "bitmask_email_output" to front of OUTPUT chain +    if not ipv4_chain_exists(BITMASK_CHAIN_EMAIL_OUT): +        ip4tables("--new-chain", BITMASK_CHAIN_EMAIL_OUT) +    if not ipv6_chain_exists(BITMASK_CHAIN_EMAIL_OUT): +        ip6tables("--new-chain", BITMASK_CHAIN_EMAIL_OUT) +    iptables("--insert", "OUTPUT", "--jump", BITMASK_CHAIN_EMAIL_OUT) + +    # Disable the access to imap and smtp from outside +    iptables("--append", BITMASK_CHAIN_EMAIL, +             "--in-interface", LOCAL_INTERFACE, "--protocol", "tcp", +             "--dport", IMAP_PORT, "--jump", "ACCEPT") +    iptables("--append", BITMASK_CHAIN_EMAIL, +             "--in-interface", LOCAL_INTERFACE, "--protocol", "tcp", +             "--dport", SMTP_PORT, "--jump", "ACCEPT") +    iptables("--append", BITMASK_CHAIN_EMAIL, +             "--protocol", "tcp", "--dport", IMAP_PORT, "--jump", "REJECT") +    iptables("--append", BITMASK_CHAIN_EMAIL, +             "--protocol", "tcp", "--dport", SMTP_PORT, "--jump", "REJECT") + +    if not args or not PARAM_FORMATS["UID"](args[0]): +        raise Exception("No uid given") +    uid = args[0] + +    # Only the unix 'uid' have access to the email imap and smtp ports +    iptables("--append", BITMASK_CHAIN_EMAIL_OUT, +             "--out-interface", LOCAL_INTERFACE, +             "--match", "owner", "--uid-owner", uid, "--protocol", "tcp", +             "--dport", IMAP_PORT, "--jump", "ACCEPT") +    iptables("--append", BITMASK_CHAIN_EMAIL_OUT, +             "--out-interface", LOCAL_INTERFACE, +             "--match", "owner", "--uid-owner", uid, "--protocol", "tcp", +             "--dport", SMTP_PORT, "--jump", "ACCEPT") +    iptables("--append", BITMASK_CHAIN_EMAIL_OUT, +             "--out-interface", LOCAL_INTERFACE, +             "--protocol", "tcp", "--dport", IMAP_PORT, "--jump", "REJECT") +    iptables("--append", BITMASK_CHAIN_EMAIL_OUT, +             "--out-interface", LOCAL_INTERFACE, +             "--protocol", "tcp", "--dport", SMTP_PORT, "--jump", "REJECT") + + +def fw_email_stop(): +    """ +    Stop the email firewall. +    """ +    ok = True + +    try: +        iptables("--delete", "INPUT", "--jump", BITMASK_CHAIN_EMAIL, +                 throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to remove bitmask email firewall from INPUT " +              "chain (maybe it is already removed?)", exc) +        ok = False + +    try: +        iptables("--delete", "OUTPUT", "--jump", BITMASK_CHAIN_EMAIL_OUT, +                 throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to remove bitmask email firewall from OUTPUT " +              "chain (maybe it is already removed?)", exc) +        ok = False + +    try: +        ip4tables("--flush", BITMASK_CHAIN_EMAIL, throw=True) +        ip4tables("--delete-chain", BITMASK_CHAIN_EMAIL, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv4 email firewall " +              "chain (maybe it is already destroyed?)", exc) +        ok = False + +    try: +        ip6tables("--flush", BITMASK_CHAIN_EMAIL, throw=True) +        ip6tables("--delete-chain", BITMASK_CHAIN_EMAIL, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv6 email firewall " +              "chain (maybe it is already destroyed?)", exc) +        ok = False + +    try: +        ip4tables("--flush", BITMASK_CHAIN_EMAIL_OUT, throw=True) +        ip4tables("--delete-chain", BITMASK_CHAIN_EMAIL_OUT, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv4 email firewall " +              "chain (maybe it is already destroyed?)", exc) +        ok = False + +    try: +        ip6tables("--flush", BITMASK_CHAIN_EMAIL_OUT, throw=True) +        ip6tables("--delete-chain", BITMASK_CHAIN_EMAIL_OUT, throw=True) +    except subprocess.CalledProcessError as exc: +        debug("INFO: not able to flush and delete bitmask ipv6 email firewall " +              "chain (maybe it is already destroyed?)", exc) +        ok = False + +    if not (ok or ipv4_chain_exists or ipv6_chain_exists): +        raise Exception("email firewall might still be left up. " +                        "Please try `fw-email stop` again.") + + +# +# MAIN +# + + +def main(): +    """ +    Entry point for cmdline execution. +    """ +    # TODO use argparse instead. + +    if len(sys.argv) >= 2: +        command = "_".join(sys.argv[1:3]) +        args = sys.argv[3:] + +        is_restart = False +        if args and args[0] == "restart": +            is_restart = True +            args.remove('restart') + +        if command == "version": +            print(VERSION) +            exit(0) + +        if os.getuid() != 0: +            bail("ERROR: must be run as root") + +        if command == "openvpn_start": +            openvpn_start(args) + +        elif command == "openvpn_stop": +            openvpn_stop(args) + +        elif command == "firewall_start": +            try: +                firewall_start(args) +            except Exception as ex: +                if not is_restart: +                    firewall_stop() +                bail("ERROR: could not start firewall", ex) + +        elif command == "firewall_stop": +            try: +                firewall_stop() +            except Exception as ex: +                bail("ERROR: could not stop firewall", ex) + +        elif command == "firewall_isup": +            if ipv4_chain_exists(BITMASK_CHAIN): +                log("%s: INFO: bitmask firewall is up" % (SCRIPT,)) +            else: +                bail("INFO: bitmask firewall is down") + +        elif command == "fw-email_start": +            try: +                fw_email_start(args) +            except Exception as ex: +                if not is_restart: +                    fw_email_stop() +                bail("ERROR: could not start email firewall", ex) + +        elif command == "fw-email_stop": +            try: +                fw_email_stop() +            except Exception as ex: +                bail("ERROR: could not stop email firewall", ex) + +        elif command == "fw-email_isup": +            if ipv4_chain_exists(BITMASK_CHAIN_EMAIL): +                log("%s: INFO: bitmask email firewall is up" % (SCRIPT,)) +            else: +                bail("INFO: bitmask email firewall is down") + +        else: +            bail("ERROR: No such command") +    else: +        bail("ERROR: No such command") + +if __name__ == "__main__": +    debug(" ".join(sys.argv)) +    main() +    log("done") +    exit(0) diff --git a/src/leap/bitmask/vpn/fw/firewall.py b/src/leap/bitmask/vpn/fw/firewall.py new file mode 100644 index 00000000..4335b8e9 --- /dev/null +++ b/src/leap/bitmask/vpn/fw/firewall.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# manager.py +# Copyright (C) 2015 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/>. + +""" +Firewall Manager +""" + +import commands +import subprocess + +from leap.bitmask.vpn.constants import IS_MAC + + +class FirewallManager(object): + +    """ +    Firewall manager that blocks/unblocks all the internet traffic with some +    exceptions. +    This allows us to achieve fail close on a vpn connection. +    """ + +    # FIXME -- get the path +    BITMASK_ROOT = "/usr/local/sbin/bitmask-root" + +    def __init__(self, remotes): +        """ +        Initialize the firewall manager with a set of remotes that we won't +        block. + +        :param remotes: the gateway(s) that we will allow +        :type remotes: list +        """ +        self._remotes = remotes + +    def start(self, restart=False): +        """ +        Launch the firewall using the privileged wrapper. + +        :returns: True if the exitcode of calling the root helper in a +                  subprocess is 0. +        :rtype: bool +        """ +        gateways = [gateway for gateway, port in self._remotes] + +        # XXX check for wrapper existence, check it's root owned etc. +        # XXX check that the iptables rules are in place. + +        cmd = ["pkexec", self.BITMASK_ROOT, "firewall", "start"] +        if restart: +            cmd.append("restart") + +        # FIXME -- use a processprotocol +        exitCode = subprocess.call(cmd + gateways) +        return True if exitCode is 0 else False + +    # def tear_down_firewall(self): +    def stop(self): +        """ +        Tear the firewall down using the privileged wrapper. +        """ +        if IS_MAC: +            # We don't support Mac so far +            return True + +        exitCode = subprocess.call(["pkexec", self.BITMASK_ROOT, +                                    "firewall", "stop"]) +        return True if exitCode is 0 else False + +    # def is_fw_down(self): +    def is_up(self): +        """ +        Return whether the firewall is up or not. + +        :rtype: bool +        """ +        # TODO test this, refactored from is_fw_down + +        cmd = "pkexec {0} firewall isup".format(self.BITMASK_ROOT) +        output = commands.getstatusoutput(cmd)[0] + +        return output != 256 diff --git a/src/leap/bitmask/vpn/launcher.py b/src/leap/bitmask/vpn/launcher.py new file mode 100644 index 00000000..c0495968 --- /dev/null +++ b/src/leap/bitmask/vpn/launcher.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +# launcher.py +# Copyright (C) 2013-2017 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/>. + +""" +Platform-independent VPN launcher interface. +""" + +import getpass +import hashlib +import os +import stat + +from twisted.logger import Logger + +from abc import ABCMeta, abstractmethod +from functools import partial + +from leap.bitmask.vpn.constants import IS_LINUX +from leap.bitmask.vpn.utils import force_eval + + +logger = Logger() + + +flags_STANDALONE = False + + +class VPNLauncherException(Exception): +    pass + + +class OpenVPNNotFoundException(VPNLauncherException): +    pass + + +def _has_updown_scripts(path, warn=True): +    """ +    Checks the existence of the up/down scripts and its +    exec bit if applicable. + +    :param path: the path to be checked +    :type path: str + +    :param warn: whether we should log the absence +    :type warn: bool + +    :rtype: bool +    """ +    is_file = os.path.isfile(path) +    if warn and not is_file: +        logger.error("Could not find up/down script %s. " +                     "Might produce DNS leaks." % (path,)) + +    # XXX check if applies in win +    is_exe = False +    try: +        is_exe = (stat.S_IXUSR & os.stat(path)[stat.ST_MODE] != 0) +    except OSError as e: +        logger.warn("%s" % (e,)) +    if warn and not is_exe: +        logger.error("Up/down script %s is not executable. " +                     "Might produce DNS leaks." % (path,)) +    return is_file and is_exe + + +def _has_other_files(path, warn=True): +    """ +    Check the existence of other important files. + +    :param path: the path to be checked +    :type path: str + +    :param warn: whether we should log the absence +    :type warn: bool + +    :rtype: bool +    """ +    is_file = os.path.isfile(path) +    if warn and not is_file: +        logger.warning("Could not find file during checks: %s. " % ( +            path,)) +    return is_file + + +class VPNLauncher(object): +    """ +    Abstract launcher class +    """ +    __metaclass__ = ABCMeta + +    UPDOWN_FILES = None +    OTHER_FILES = None +    UP_SCRIPT = None +    DOWN_SCRIPT = None + +    PREFERRED_PORTS = ("443", "80", "53", "1194") + +    @classmethod +    @abstractmethod +    def get_gateways(kls, eipconfig, providerconfig): +        """ +        Return a list with the selected gateways for a given provider, looking +        at the EIP config file. +        Each item of the list is a tuple containing (gateway, port). + +        :param eipconfig: eip configuration object +        :type eipconfig: EIPConfig + +        :param providerconfig: provider specific configuration +        :type providerconfig: ProviderConfig + +        :rtype: list +        """ +        gateways = [] + +        settings = Settings() +        domain = providerconfig.get_domain() +        gateway_conf = settings.get_selected_gateway(domain) +        gateway_selector = VPNGatewaySelector(eipconfig) + +        if gateway_conf == GATEWAY_AUTOMATIC: +            gws = gateway_selector.get_gateways() +        else: +            gws = [gateway_conf] + +        if not gws: +            logger.error('No gateway was found!') +            raise VPNLauncherException('No gateway was found!') + +        for idx, gw in enumerate(gws): +            ports = eipconfig.get_gateway_ports(idx) + +            the_port = "1194"  # default port + +            # pick the port preferring this order: +            for port in kls.PREFERRED_PORTS: +                if port in ports: +                    the_port = port +                    break +                else: +                    continue + +            gateways.append((gw, the_port)) + +        logger.debug("Using gateways (ip, port): {0!r}".format(gateways)) +        return gateways + +    @classmethod +    @abstractmethod +    def get_vpn_command(kls, eipconfig, providerconfig, +                        socket_host, socket_port, remotes, openvpn_verb=1): +        """ +        Return the platform-dependant vpn command for launching openvpn. + +        Might raise: +            OpenVPNNotFoundException, +            VPNLauncherException. + +        :param eipconfig: eip configuration object +        :type eipconfig: EIPConfig +        :param providerconfig: provider specific configuration +        :type providerconfig: ProviderConfig +        :param socket_host: either socket path (unix) or socket IP +        :type socket_host: str +        :param socket_port: either string "unix" if it's a unix socket, +                            or port otherwise +        :type socket_port: str +        :param openvpn_verb: the openvpn verbosity wanted +        :type openvpn_verb: int + +        :return: A VPN command ready to be launched. +        :rtype: list +        """ +        # leap_assert_type(eipconfig, EIPConfig) +        # leap_assert_type(providerconfig, ProviderConfig) + +        # XXX this still has to be changed on osx and windows accordingly +        # kwargs = {} +        # openvpn_possibilities = which(kls.OPENVPN_BIN, **kwargs) +        # if not openvpn_possibilities: +        #     raise OpenVPNNotFoundException() +        # openvpn = first(openvpn_possibilities) +        # ----------------------------------------- +        openvpn_path = force_eval(kls.OPENVPN_BIN_PATH) + +        if not os.path.isfile(openvpn_path): +            logger.warning("Could not find openvpn bin in path %s" % ( +                openvpn_path)) +            raise OpenVPNNotFoundException() + +        args = [] + +        args += [ +            '--setenv', "LEAPOPENVPN", "1", +            '--nobind' +        ] + +        if openvpn_verb is not None: +            args += ['--verb', '%d' % (openvpn_verb,)] + +        # gateways = kls.get_gateways(eipconfig, providerconfig) +        gateways = remotes + +        for ip, port in gateways: +            args += ['--remote', ip, port, 'udp'] + +        args += [ +            '--client', +            '--dev', 'tun', +            '--persist-key', +            '--tls-client', +            '--remote-cert-tls', +            'server' +        ] + +        openvpn_configuration = eipconfig.get_openvpn_configuration() +        for key, value in openvpn_configuration.items(): +            args += ['--%s' % (key,), value] + +        user = getpass.getuser() + +        if socket_port == "unix":  # that's always the case for linux +            args += [ +                '--management-client-user', user +            ] + +        args += [ +            '--management-signal', +            '--management', socket_host, socket_port, +            '--script-security', '2' +        ] + +        if kls.UP_SCRIPT is not None: +            if _has_updown_scripts(kls.UP_SCRIPT): +                args += [ +                    '--up', '\"%s\"' % (kls.UP_SCRIPT,), +                ] + +        if kls.DOWN_SCRIPT is not None: +            if _has_updown_scripts(kls.DOWN_SCRIPT): +                args += [ +                    '--down', '\"%s\"' % (kls.DOWN_SCRIPT,) +                ] + +        args += [ +            '--cert', eipconfig.get_client_cert_path(providerconfig), +            '--key', eipconfig.get_client_cert_path(providerconfig), +            '--ca', providerconfig.get_ca_cert_path() +        ] + +        args += [ +            '--ping', '10', +            '--ping-restart', '30'] + +        command_and_args = [openvpn_path] + args +        return command_and_args + +    @classmethod +    def get_vpn_env(kls): +        """ +        Return a dictionary with the custom env for the platform. +        This is mainly used for setting LD_LIBRARY_PATH to the correct +        path when distributing a standalone client + +        :rtype: dict +        """ +        return {} + +    @classmethod +    def missing_updown_scripts(kls): +        """ +        Return what updown scripts are missing. + +        :rtype: list +        """ +        # FIXME +        # XXX remove method when we ditch UPDOWN in osx and win too +        if IS_LINUX: +            return [] +        else: +            # leap_assert(kls.UPDOWN_FILES is not None, +            #             "Need to define UPDOWN_FILES for this particular " +            #             "launcher before calling this method") +            # TODO assert vs except? +            if kls.UPDOWN_FILES is None: +                raise Exception( +                    "Need to define UPDOWN_FILES for this particular " +                    "launcher before calling this method") +            file_exist = partial(_has_updown_scripts, warn=False) +            zipped = zip(kls.UPDOWN_FILES, map(file_exist, kls.UPDOWN_FILES)) +            missing = filter(lambda (path, exists): exists is False, zipped) +            return [path for path, exists in missing] + +    @classmethod +    def missing_other_files(kls): +        """ +        Return what other important files are missing during startup. +        Same as missing_updown_scripts but does not check for exec bit. + +        :rtype: list +        """ +        # leap_assert(kls.OTHER_FILES is not None, +        #             "Need to define OTHER_FILES for this particular " +        #             "auncher before calling this method") + +        # TODO assert vs except? +        if kls.OTHER_FILES is None: +            raise Exception( +                "Need to define OTHER_FILES for this particular " +                "auncher before calling this method") + +        other = force_eval(kls.OTHER_FILES) +        file_exist = partial(_has_other_files, warn=False) + +        if flags_STANDALONE: +            try: +                from leap.bitmask import _binaries +            except ImportError: +                raise RuntimeError( +                    "Could not find binary hash info in this bundle!") + +            _, bitmask_root_path, openvpn_bin_path = other + +            check_hash = _has_expected_binary_hash +            openvpn_hash = _binaries.OPENVPN_BIN +            bitmask_root_hash = _binaries.BITMASK_ROOT + +            correct_hash = ( +                True,  # we do not check the polkit file +                check_hash(bitmask_root_path, bitmask_root_hash), +                check_hash(openvpn_bin_path, openvpn_hash)) + +            zipped = zip(other, map(file_exist, other), correct_hash) +            missing = filter( +                lambda (path, exists, hash_ok): ( +                    exists is False or hash_ok is False), +                zipped) +            return [path for path, exists, hash_ok in missing] +        else: +            zipped = zip(other, map(file_exist, other)) +            missing = filter(lambda (path, exists): exists is False, zipped) +            return [path for path, exists in missing] + + +def _has_expected_binary_hash(path, expected_hash): +    """ +    Check if the passed path matches the expected hash. + +    Used from within the bundle, to know if we have to reinstall the shipped +    binaries into the system path. + +    This path will be /usr/local/sbin for linux. + +    :param path: the path to check. +    :type path: str +    :param expected_hash: the sha256 hash that we expect +    :type expected_hash: str +    :rtype: bool +    """ +    try: +        with open(path) as f: +            file_hash = hashlib.sha256(f.read()).hexdigest() +        return expected_hash == file_hash +    except IOError: +        return False diff --git a/src/leap/bitmask/vpn/launchers/__init__.py b/src/leap/bitmask/vpn/launchers/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/leap/bitmask/vpn/launchers/__init__.py diff --git a/src/leap/bitmask/vpn/launchers/darwin.py b/src/leap/bitmask/vpn/launchers/darwin.py new file mode 100644 index 00000000..f19404c3 --- /dev/null +++ b/src/leap/bitmask/vpn/launchers/darwin.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# darwin.py +# Copyright (C) 2013-2017 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/>. +""" +Darwin VPN launcher implementation. +""" +import commands +import getpass +import os +import sys + +from twisted.logger import Logger + +from leap.bitmask.vpn.launcher import VPNLauncher +from leap.bitmask.vpn.launcher import VPNLauncherException +from leap.bitmask.vpn.utils import get_path_prefix + + +logger = Logger() + + +class EIPNoTunKextLoaded(VPNLauncherException): +    pass + + +class DarwinVPNLauncher(VPNLauncher): +    """ +    VPN launcher for the Darwin Platform +    """ +    COCOASUDO = "cocoasudo" +    # XXX need the good old magic translate for these strings +    # (look for magic in 0.2.0 release) +    SUDO_MSG = ("Bitmask needs administrative privileges to run " +                "Encrypted Internet.") +    INSTALL_MSG = ("\"Bitmask needs administrative privileges to install " +                   "missing scripts and fix permissions.\"") + +    # Hardcode the installation path for OSX for security, openvpn is +    # run as root +    INSTALL_PATH = "/Applications/Bitmask.app/" +    INSTALL_PATH_ESCAPED = os.path.realpath(os.getcwd() + "/../../") +    OPENVPN_BIN = 'openvpn.leap' +    OPENVPN_PATH = "%s/Contents/Resources/openvpn" % (INSTALL_PATH,) +    OPENVPN_PATH_ESCAPED = "%s/Contents/Resources/openvpn" % ( +        INSTALL_PATH_ESCAPED,) +    OPENVPN_BIN_PATH = "%s/Contents/Resources/%s" % (INSTALL_PATH, +                                                     OPENVPN_BIN) + +    UP_SCRIPT = "%s/client.up.sh" % (OPENVPN_PATH,) +    DOWN_SCRIPT = "%s/client.down.sh" % (OPENVPN_PATH,) +    OPENVPN_DOWN_PLUGIN = '%s/openvpn-down-root.so' % (OPENVPN_PATH,) + +    UPDOWN_FILES = (UP_SCRIPT, DOWN_SCRIPT, OPENVPN_DOWN_PLUGIN) +    OTHER_FILES = [] + +    @classmethod +    def cmd_for_missing_scripts(kls, frompath): +        """ +        Returns a command that can copy the missing scripts. +        :rtype: str +        """ +        to = kls.OPENVPN_PATH_ESCAPED + +        cmd = "#!/bin/sh\n" +        cmd += "mkdir -p {0}\n".format(to) +        cmd += "cp '{0}'/* {1}\n".format(frompath, to) +        cmd += "chmod 744 {0}/*".format(to) + +        return cmd + +    @classmethod +    def is_kext_loaded(kls): +        """ +        Checks if the needed kext is loaded before launching openvpn. + +        :returns: True if kext is loaded, False otherwise. +        :rtype: bool +        """ +        return bool(commands.getoutput('kextstat | grep "leap.tun"')) + +    @classmethod +    def _get_icon_path(kls): +        """ +        Returns the absolute path to the app icon. + +        :rtype: str +        """ +        resources_path = os.path.abspath( +            os.path.join(os.getcwd(), "../../Contents/Resources")) + +        return os.path.join(resources_path, "bitmask.tiff") + +    @classmethod +    def get_cocoasudo_ovpn_cmd(kls): +        """ +        Returns a string with the cocoasudo command needed to run openvpn +        as admin with a nice password prompt. The actual command needs to be +        appended. + +        :rtype: (str, list) +        """ +        # TODO add translation support for this +        sudo_msg = ("Bitmask needs administrative privileges to run " +                    "Encrypted Internet.") +        iconpath = kls._get_icon_path() +        has_icon = os.path.isfile(iconpath) +        args = ["--icon=%s" % iconpath] if has_icon else [] +        args.append("--prompt=%s" % (sudo_msg,)) + +        return kls.COCOASUDO, args + +    @classmethod +    def get_cocoasudo_installmissing_cmd(kls): +        """ +        Returns a string with the cocoasudo command needed to install missing +        files as admin with a nice password prompt. The actual command needs to +        be appended. + +        :rtype: (str, list) +        """ +        # TODO add translation support for this +        install_msg = ('"Bitmask needs administrative privileges to install ' +                       'missing scripts and fix permissions."') +        iconpath = kls._get_icon_path() +        has_icon = os.path.isfile(iconpath) +        args = ["--icon=%s" % iconpath] if has_icon else [] +        args.append("--prompt=%s" % (install_msg,)) + +        return kls.COCOASUDO, args + +    @classmethod +    def get_vpn_command(kls, eipconfig, providerconfig, socket_host, +                        socket_port="unix", openvpn_verb=1): +        """ +        Returns the OSX implementation for the vpn launching command. + +        Might raise: +            EIPNoTunKextLoaded, +            OpenVPNNotFoundException, +            VPNLauncherException. + +        :param eipconfig: eip configuration object +        :type eipconfig: EIPConfig +        :param providerconfig: provider specific configuration +        :type providerconfig: ProviderConfig +        :param socket_host: either socket path (unix) or socket IP +        :type socket_host: str +        :param socket_port: either string "unix" if it's a unix socket, +                            or port otherwise +        :type socket_port: str +        :param openvpn_verb: the openvpn verbosity wanted +        :type openvpn_verb: int + +        :return: A VPN command ready to be launched. +        :rtype: list +        """ +        if not kls.is_kext_loaded(): +            raise EIPNoTunKextLoaded + +        # we use `super` in order to send the class to use +        command = super(DarwinVPNLauncher, kls).get_vpn_command( +            eipconfig, providerconfig, socket_host, socket_port, openvpn_verb) + +        cocoa, cargs = kls.get_cocoasudo_ovpn_cmd() +        cargs.extend(command) +        command = cargs +        command.insert(0, cocoa) + +        command.extend(['--setenv', "LEAPUSER", getpass.getuser()]) + +        return command + +    @classmethod +    def get_vpn_env(kls): +        """ +        Returns a dictionary with the custom env for the platform. +        This is mainly used for setting LD_LIBRARY_PATH to the correct +        path when distributing a standalone client + +        :rtype: dict +        """ +        ld_library_path = os.path.join(get_path_prefix(), "..", "lib") +        ld_library_path.encode(sys.getfilesystemencoding()) +        return { +            "DYLD_LIBRARY_PATH": ld_library_path +        } diff --git a/src/leap/bitmask/vpn/launchers/linux.py b/src/leap/bitmask/vpn/launchers/linux.py new file mode 100644 index 00000000..a86dcb45 --- /dev/null +++ b/src/leap/bitmask/vpn/launchers/linux.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# linux +# Copyright (C) 2013-2017 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/>. + +""" +Linux VPN launcher implementation. +""" + +import commands +import os +import sys + +from twisted.logger import Logger + +from leap.bitmask.vpn.utils import first +from leap.bitmask.vpn.utils import get_path_prefix, force_eval +from leap.bitmask.vpn.privilege import LinuxPolicyChecker +from leap.bitmask.vpn.privilege import NoPkexecAvailable +from leap.bitmask.vpn.privilege import NoPolkitAuthAgentAvailable +from leap.bitmask.vpn.launcher import VPNLauncher +from leap.bitmask.vpn.launcher import VPNLauncherException + +logger = Logger() +COM = commands +flags_STANDALONE = False + + +class EIPNoPolkitAuthAgentAvailable(VPNLauncherException): +    pass + + +class EIPNoPkexecAvailable(VPNLauncherException): +    pass + + +SYSTEM_CONFIG = "/etc/leap" +leapfile = lambda f: "%s/%s" % (SYSTEM_CONFIG, f) + + +class LinuxVPNLauncher(VPNLauncher): + +    # The following classes depend on force_eval to be called against +    # the classes, to get the evaluation of the standalone flag on runtine. +    # If we keep extending this kind of classes, we should abstract the +    # handling of the STANDALONE flag in a base class + +    class BITMASK_ROOT(object): +        def __call__(self): +            return ("/usr/local/sbin/bitmask-root" if flags_STANDALONE else +                    "/usr/sbin/bitmask-root") + +    class OPENVPN_BIN_PATH(object): +        def __call__(self): +            return ("/usr/local/sbin/leap-openvpn" if flags_STANDALONE else +                    "/usr/sbin/openvpn") + +    class POLKIT_PATH(object): +        def __call__(self): +            # LinuxPolicyChecker will give us the right path if standalone. +            return LinuxPolicyChecker.get_polkit_path() + +    OTHER_FILES = (POLKIT_PATH, BITMASK_ROOT, OPENVPN_BIN_PATH) + +    @classmethod +    def get_vpn_command(kls, eipconfig, providerconfig, socket_host, +                        remotes, socket_port="unix", openvpn_verb=1): +        """ +        Returns the Linux implementation for the vpn launching command. + +        Might raise: +            EIPNoPkexecAvailable, +            EIPNoPolkitAuthAgentAvailable, +            OpenVPNNotFoundException, +            VPNLauncherException. + +        :param eipconfig: eip configuration object +        :type eipconfig: EIPConfig +        :param providerconfig: provider specific configuration +        :type providerconfig: ProviderConfig +        :param socket_host: either socket path (unix) or socket IP +        :type socket_host: str +        :param socket_port: either string "unix" if it's a unix socket, +                            or port otherwise +        :type socket_port: str +        :param openvpn_verb: the openvpn verbosity wanted +        :type openvpn_verb: int + +        :return: A VPN command ready to be launched. +        :rtype: list +        """ +        # we use `super` in order to send the class to use +        command = super(LinuxVPNLauncher, kls).get_vpn_command( +            eipconfig, providerconfig, socket_host, socket_port, remotes, +            openvpn_verb) + +        command.insert(0, force_eval(kls.BITMASK_ROOT)) +        command.insert(1, "openvpn") +        command.insert(2, "start") + +        policyChecker = LinuxPolicyChecker() +        try: +            pkexec = policyChecker.maybe_pkexec() +        except NoPolkitAuthAgentAvailable: +            raise EIPNoPolkitAuthAgentAvailable() +        except NoPkexecAvailable: +            raise EIPNoPkexecAvailable() +        if pkexec: +            command.insert(0, first(pkexec)) + +        return command + +    @classmethod +    def cmd_for_missing_scripts(kls, frompath): +        """ +        Returns a sh script that can copy the missing files. + +        :param frompath: The path where the helper files live +        :type frompath: str + +        :rtype: str +        """ +        bin_paths = force_eval( +            (LinuxVPNLauncher.POLKIT_PATH, +             LinuxVPNLauncher.OPENVPN_BIN_PATH, +             LinuxVPNLauncher.BITMASK_ROOT)) + +        polkit_path, openvpn_bin_path, bitmask_root = bin_paths + +        # no system config for now +        # sys_config = kls.SYSTEM_CONFIG +        (polkit_file, openvpn_bin_file, +         bitmask_root_file) = map( +            lambda p: os.path.split(p)[-1], +            bin_paths) + +        cmd = '#!/bin/sh\n' +        cmd += 'mkdir -p /usr/local/sbin\n' + +        cmd += 'cp "%s" "%s"\n' % (os.path.join(frompath, polkit_file), +                                   polkit_path) +        cmd += 'chmod 644 "%s"\n' % (polkit_path, ) + +        cmd += 'cp "%s" "%s"\n' % (os.path.join(frompath, bitmask_root_file), +                                   bitmask_root) +        cmd += 'chmod 744 "%s"\n' % (bitmask_root, ) + +        if flags_STANDALONE: +            cmd += 'cp "%s" "%s"\n' % ( +                os.path.join(frompath, openvpn_bin_file), +                openvpn_bin_path) +            cmd += 'chmod 744 "%s"\n' % (openvpn_bin_path, ) + +        return cmd + +    @classmethod +    def get_vpn_env(kls): +        """ +        Returns a dictionary with the custom env for the platform. +        This is mainly used for setting LD_LIBRARY_PATH to the correct +        path when distributing a standalone client + +        :rtype: dict +        """ +        ld_library_path = os.path.join(get_path_prefix(), "..", "lib") +        ld_library_path.encode(sys.getfilesystemencoding()) +        return { +            "LD_LIBRARY_PATH": ld_library_path +        } diff --git a/src/leap/bitmask/vpn/launchers/windows.py b/src/leap/bitmask/vpn/launchers/windows.py new file mode 100644 index 00000000..bfaac2fc --- /dev/null +++ b/src/leap/bitmask/vpn/launchers/windows.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# windows.py +# Copyright (C) 2013-2017 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/>. + +""" +Windows VPN launcher implementation. +""" + +from twisted.logger import Logger + +from leap.bitmask.vpn.launcher import VPNLauncher + + +logger = get_logger() + + +class WindowsVPNLauncher(VPNLauncher): +    """ +    VPN launcher for the Windows platform +    """ + +    OPENVPN_BIN = 'openvpn_leap.exe' + +    # XXX UPDOWN_FILES ... we do not have updown files defined yet! +    # (and maybe we won't) + +    @classmethod +    def get_vpn_command(kls, eipconfig, providerconfig, socket_host, +                        socket_port="9876", openvpn_verb=1): +        """ +        Returns the Windows implementation for the vpn launching command. + +        Might raise: +            OpenVPNNotFoundException, +            VPNLauncherException. + +        :param eipconfig: eip configuration object +        :type eipconfig: EIPConfig +        :param providerconfig: provider specific configuration +        :type providerconfig: ProviderConfig +        :param socket_host: either socket path (unix) or socket IP +        :type socket_host: str +        :param socket_port: either string "unix" if it's a unix socket, +                            or port otherwise +        :type socket_port: str +        :param openvpn_verb: the openvpn verbosity wanted +        :type openvpn_verb: int + +        :return: A VPN command ready to be launched. +        :rtype: list +        """ +        # TODO add check for this +        # leap_assert(socket_port != "unix", +        #             "We cannot use unix sockets in windows!") + +        # we use `super` in order to send the class to use +        command = super(WindowsVPNLauncher, kls).get_vpn_command( +            eipconfig, providerconfig, socket_host, socket_port, openvpn_verb) + +        return command diff --git a/src/leap/bitmask/vpn/manager.py b/src/leap/bitmask/vpn/manager.py new file mode 100644 index 00000000..24033273 --- /dev/null +++ b/src/leap/bitmask/vpn/manager.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# manager.py +# Copyright (C) 2015 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/>. + +""" +VPN Manager +""" + +import os +import tempfile + +from leap.bitmask.vpn import process +from leap.bitmask.vpn.constants import IS_WIN + + +class _TempEIPConfig(object): +    """Current EIP code on bitmask depends on EIPConfig object, this temporary +    implementation helps on the transition.""" + +    def __init__(self, flags, path, ports): +        self._flags = flags +        self._path = path +        self._ports = ports + +    def get_gateway_ports(self, idx): +        return self._ports + +    def get_openvpn_configuration(self): +        return self._flags + +    def get_client_cert_path(self, providerconfig): +        return self._path + + +class _TempProviderConfig(object): +    """Current EIP code on bitmask depends on ProviderConfig object, this +    temporary implementation helps on the transition.""" + +    def __init__(self, domain, path): +        self._domain = domain +        self._path = path + +    def get_domain(self): +        return self._domain + +    def get_ca_cert_path(self): +        return self._path + + +class VPNManager(object): + +    def __init__(self, remotes, cert_path, key_path, ca_path, extra_flags, +                 mock_signaler): +        """ +        Initialize the VPNManager object. + +        :param remotes: a list of gateways tuple (ip, port) looking like this: +            ((ip1, portA), (ip2, portB), ...) +        :type remotes: tuple of tuple(str, int) +        """ +        # TODO we can set all the needed ports, gateways and paths in here +        ports = [] + +        # this seems to be obsolete, needed to get gateways +        domain = "demo.bitmask.net" +        self._remotes = remotes + +        self._eipconfig = _TempEIPConfig(extra_flags, cert_path, ports) +        self._providerconfig = _TempProviderConfig(domain, ca_path) +        # signaler = None  # XXX handle signaling somehow... +        signaler = mock_signaler +        self._vpn = process.VPN(remotes=remotes, signaler=signaler) + +    def start(self): +        """ +        Start the VPN process. + +        VPN needs: +        * paths for: cert, key, ca +        * gateway, port +        * domain name +        """ +        host, port = self._get_management() + +        # TODO need gateways here +        # sorting them doesn't belong in here +        # gateways = ((ip1, portA), (ip2, portB), ...) + +        self._vpn.start(eipconfig=self._eipconfig, +                        providerconfig=self._providerconfig, +                        socket_host=host, socket_port=port) +        return True + +    def stop(self): +        """ +        Bring openvpn down using the privileged wrapper. + +        :returns: True if succeeded, False otherwise. +        :rtype: bool +        """ +        self._vpn.terminate(False, False)  # TODO review params + +        # TODO how to return False if this fails +        return True + +    def is_up(self): +        """ +        Return whether the VPN is up or not. + +        :rtype: bool +        """ +        pass + +    def kill(self): +        """ +        Sends a kill signal to the openvpn process. +        """ +        pass +        # self._vpn.killit() + +    def terminate(self): +        """ +        Stop the openvpn subprocess. + +        Attempts to send a SIGTERM first, and after a timeout it sends a +        SIGKILL. +        """ +        pass + +    def _get_management(self): +        """ +        Return a tuple with the host (socket) and port to be used for VPN. + +        :return: (host, port) +        :rtype: tuple (str, str) +        """ +        if IS_WIN: +            host = "localhost" +            port = "9876" +        else: +            # XXX cleanup this on exit too +            # XXX atexit.register ? +            host = os.path.join(tempfile.mkdtemp(prefix="leap-tmp"), +                                'openvpn.socket') +            port = "unix" + +        return host, port diff --git a/src/leap/bitmask/vpn/privilege.py b/src/leap/bitmask/vpn/privilege.py new file mode 100644 index 00000000..e8ed5576 --- /dev/null +++ b/src/leap/bitmask/vpn/privilege.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# privilege_policies.py +# Copyright (C) 2013 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/>. + +""" +Helpers to determine if the needed policies for privilege escalation +are operative under this client run. +""" + +import commands +import os +import subprocess +import platform +import time + +from abc import ABCMeta, abstractmethod + +from twisted.logger import Logger +from twisted.python.procutils import which + +logger = Logger() + + +flags_STANDALONE = False + + +class NoPolkitAuthAgentAvailable(Exception): +    pass + + +class NoPkexecAvailable(Exception): +    pass + + +def is_missing_policy_permissions(): +    """ +    Returns True if we do not have implemented a policy checker for this +    platform, or if the policy checker exists but it cannot find the +    appropriate policy mechanisms in place. + +    :rtype: bool +    """ +    _system = platform.system() +    platform_checker = _system + "PolicyChecker" +    policy_checker = globals().get(platform_checker, None) +    if not policy_checker: +        # it is true that we miss permission to escalate +        # privileges without asking for password each time. +        logger.debug("we could not find a policy checker implementation " +                     "for %s" % (_system,)) +        return True +    return policy_checker().is_missing_policy_permissions() + + +class PolicyChecker: +    """ +    Abstract PolicyChecker class +    """ + +    __metaclass__ = ABCMeta + +    @abstractmethod +    def is_missing_policy_permissions(self): +        """ +        Returns True if we could not find any policy mechanisms that +        are defined to be in used for this particular platform. + +        :rtype: bool +        """ +        return True + + +class LinuxPolicyChecker(PolicyChecker): +    """ +    PolicyChecker for Linux +    """ +    LINUX_POLKIT_FILE = ("/usr/share/polkit-1/actions/" +                         "se.leap.bitmask.policy") +    LINUX_POLKIT_FILE_BUNDLE = ("/usr/share/polkit-1/actions/" +                                "se.leap.bitmask.bundle.policy") +    PKEXEC_BIN = 'pkexec' + +    @classmethod +    def get_polkit_path(self): +        """ +        Returns the polkit file path. + +        :rtype: str +        """ +        return (self.LINUX_POLKIT_FILE_BUNDLE if flags_STANDALONE +                else self.LINUX_POLKIT_FILE) + +    def is_missing_policy_permissions(self): +        # FIXME this name is quite confusing, it does not have anything to do +        # with file permissions. +        """ +        Returns True if we could not find the appropriate policykit file +        in place + +        :rtype: bool +        """ +        path = self.get_polkit_path() +        return not os.path.isfile(path) + +    @classmethod +    def maybe_pkexec(self): +        """ +        Checks whether pkexec is available in the system, and +        returns the path if found. + +        Might raise: +            NoPkexecAvailable, +            NoPolkitAuthAgentAvailable. + +        :returns: a list of the paths where pkexec is to be found +        :rtype: list +        """ +        if self._is_pkexec_in_system(): +            if not self.is_up(): +                self.launch() +                time.sleep(2) +            if self.is_up(): +                pkexec_possibilities = which(self.PKEXEC_BIN) +                # leap_assert(len(pkexec_possibilities) > 0, +                #             "We couldn't find pkexec") +                if not pkexec_possibilities: +                    logger.error("We couldn't find pkexec") +                    raise Exception("We couldn't find pkexec") +                return pkexec_possibilities +            else: +                logger.warning("No polkit auth agent found. pkexec " + +                               "will use its own auth agent.") +                raise NoPolkitAuthAgentAvailable() +        else: +            logger.warning("System has no pkexec") +            raise NoPkexecAvailable() + +    @classmethod +    def launch(self): +        """ +        Tries to launch policykit +        """ +        env = None +        if flags_STANDALONE: +            # This allows us to send to subprocess the environment configs that +            # works for the standalone bundle (like the PYTHONPATH) +            env = dict(os.environ) +            # The LD_LIBRARY_PATH is set on the launcher but not forwarded to +            # subprocess unless we do so explicitly. +            env["LD_LIBRARY_PATH"] = os.path.abspath("./lib/") +        try: +            # We need to quote the command because subprocess call +            # will do "sh -c 'foo'", so if we do not quoute it we'll end +            # up with a invocation to the python interpreter. And that +            # is bad. +            logger.debug("Trying to launch polkit agent") +            subprocess.call(["python -m leap.bitmask.util.polkit_agent"], +                            shell=True, env=env) +        except Exception as exc: +            logger.exception(exc) + +    @classmethod +    def is_up(self): +        """ +        Checks if a polkit daemon is running. + +        :return: True if it's running, False if it's not. +        :rtype: boolean +        """ +        # Note that gnome-shell does not uses a separate process for the +        # polkit-agent, it uses a polkit-agent within its own process so we +        # can't ps-grep a polkit process, we can ps-grep gnome-shell itself. + +        # the [x] thing is to avoid grep match itself +        polkit_options = [ +            'ps aux | grep "polkit-[g]nome-authentication-agent-1"', +            'ps aux | grep "polkit-[k]de-authentication-agent-1"', +            'ps aux | grep "polkit-[m]ate-authentication-agent-1"', +            'ps aux | grep "[l]xpolkit"', +            'ps aux | grep "[l]xsession"', +            'ps aux | grep "[g]nome-shell"', +            'ps aux | grep "[f]ingerprint-polkit-agent"', +            'ps aux | grep "[x]fce-polkit"', +        ] +        is_running = [commands.getoutput(cmd) for cmd in polkit_options] + +        return any(is_running) + +    @classmethod +    def _is_pkexec_in_system(self): +        """ +        Checks the existence of the pkexec binary in system. +        """ +        pkexec_path = which('pkexec') +        if len(pkexec_path) == 0: +            return False +        return True diff --git a/src/leap/bitmask/vpn/process.py b/src/leap/bitmask/vpn/process.py new file mode 100644 index 00000000..05847e21 --- /dev/null +++ b/src/leap/bitmask/vpn/process.py @@ -0,0 +1,920 @@ +# -*- coding: utf-8 -*- +# process.py +# Copyright (C) 2013-2017 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/>. + +""" +VPN Manager, spawned in a custom processProtocol. +""" + +import os +import shutil +import socket +import subprocess +import sys + +from itertools import chain, repeat + +import psutil +try: +    # psutil < 2.0.0 +    from psutil.error import AccessDenied as psutil_AccessDenied +    PSUTIL_2 = False +except ImportError: +    # psutil >= 2.0.0 +    from psutil import AccessDenied as psutil_AccessDenied +    PSUTIL_2 = True + +from twisted.internet import defer, protocol, reactor +from twisted.internet import error as internet_error +from twisted.internet.task import LoopingCall +from twisted.logger import Logger + +from leap.bitmask.vpn.constants import IS_MAC +from leap.bitmask.vpn.utils import first, force_eval +from leap.bitmask.vpn.utils import get_vpn_launcher +from leap.bitmask.vpn.launchers import linux +from leap.bitmask.vpn.udstelnet import UDSTelnet + +logger = Logger() + + +# OpenVPN verbosity level - from flags.py +OPENVPN_VERBOSITY = 1 + + +class VPNObserver(object): +    """ +    A class containing different patterns in the openvpn output that +    we can react upon. +    """ + +    # TODO this is i18n-sensitive, right? +    # in that case, we should add the translations :/ +    # until we find something better. + +    _events = { +        'NETWORK_UNREACHABLE': ( +            'Network is unreachable (code=101)',), +        'PROCESS_RESTART_TLS': ( +            "SIGTERM[soft,tls-error]",), +        'PROCESS_RESTART_PING': ( +            "SIGTERM[soft,ping-restart]",), +        'INITIALIZATION_COMPLETED': ( +            "Initialization Sequence Completed",), +    } + +    def __init__(self, signaler=None): +        self._signaler = signaler + +    def watch(self, line): +        """ +        Inspects line searching for the different patterns. If a match +        is found, try to emit the corresponding signal. + +        :param line: a line of openvpn output +        :type line: str +        """ +        chained_iter = chain(*[ +            zip(repeat(key, len(l)), l) +            for key, l in self._events.iteritems()]) +        for event, pattern in chained_iter: +            if pattern in line: +                logger.debug('pattern matched! %s' % pattern) +                break +        else: +            return + +        sig = self._get_signal(event) +        if sig is not None: +            if self._signaler is not None: +                self._signaler.signal(sig) +            return +        else: +            logger.debug('We got %s event from openvpn output but we could ' +                         'not find a matching signal for it.' % event) + +    def _get_signal(self, event): +        """ +        Tries to get the matching signal from the eip signals +        objects based on the name of the passed event (in lowercase) + +        :param event: the name of the event that we want to get a signal for +        :type event: str +        :returns: a Signaler signal or None +        :rtype: str or None +        """ +        if self._signaler is None: +            return +        sig = self._signaler +        signals = { +            "network_unreachable": sig.eip_network_unreachable, +            "process_restart_tls": sig.eip_process_restart_tls, +            "process_restart_ping": sig.eip_process_restart_ping, +            "initialization_completed": sig.eip_connected +        } +        return signals.get(event.lower()) + + +class OpenVPNAlreadyRunning(Exception): +    message = ("Another openvpn instance is already running, and could " +               "not be stopped.") + + +class AlienOpenVPNAlreadyRunning(Exception): +    message = ("Another openvpn instance is already running, and could " +               "not be stopped because it was not launched by LEAP.") + + +class VPN(object): +    """ +    This is the high-level object that the GUI is dealing with. +    It exposes the start and terminate methods. + +    On start, it spawns a VPNProcess instance that will use a vpnlauncher +    suited for the running platform and connect to the management interface +    opened by the openvpn process, executing commands over that interface on +    demand. +    """ +    TERMINATE_MAXTRIES = 10 +    TERMINATE_WAIT = 1  # secs + +    OPENVPN_VERB = "openvpn_verb" + +    def __init__(self, **kwargs): +        """ +        Instantiate empty attributes and get a copy +        of a QObject containing the QSignals that we will pass along +        to the VPNManager. +        """ +        self._vpnproc = None +        self._pollers = [] + +        self._signaler = kwargs['signaler'] +        # self._openvpn_verb = flags.OPENVPN_VERBOSITY +        self._openvpn_verb = None + +        self._user_stopped = False +        self._remotes = kwargs['remotes'] + +    def start(self, *args, **kwargs): +        """ +        Starts the openvpn subprocess. + +        :param args: args to be passed to the VPNProcess +        :type args: tuple + +        :param kwargs: kwargs to be passed to the VPNProcess +        :type kwargs: dict +        """ +        logger.debug('VPN: start') +        self._user_stopped = False +        self._stop_pollers() +        kwargs['openvpn_verb'] = self._openvpn_verb +        kwargs['signaler'] = self._signaler +        kwargs['remotes'] = self._remotes + +        # start the main vpn subprocess +        vpnproc = VPNProcess(*args, **kwargs) + +        if vpnproc.get_openvpn_process(): +            logger.info("Another vpn process is running. Will try to stop it.") +            vpnproc.stop_if_already_running() + +        # FIXME it would be good to document where the +        # errors here are catched, since we currently handle them +        # at the frontend layer. This *should* move to be handled entirely +        # in the backend. +        # exception is indeed technically catched in backend, then converted +        # into a signal, that is catched in the eip_status widget in the +        # frontend, and converted into a signal to abort the connection that is +        # sent to the backend again. + +        # the whole exception catching should be done in the backend, without +        # the ping-pong to the frontend, and without adding any logical checks +        # in the frontend. We should just communicate UI changes to frontend, +        # and abstract us away from anything else. +        try: +            cmd = vpnproc.getCommand() +        except Exception as e: +            logger.error("Error while getting vpn command... {0!r}".format(e)) +            raise + +        env = os.environ +        for key, val in vpnproc.vpn_env.items(): +            env[key] = val + +        reactor.spawnProcess(vpnproc, cmd[0], cmd, env) +        self._vpnproc = vpnproc + +        # add pollers for status and state +        # this could be extended to a collection of +        # generic watchers + +        poll_list = [LoopingCall(vpnproc.pollStatus), +                     LoopingCall(vpnproc.pollState)] +        self._pollers.extend(poll_list) +        self._start_pollers() + +    def bitmask_root_vpn_down(self): +        """ +        Bring openvpn down using the privileged wrapper. +        """ +        if IS_MAC: +            # We don't support Mac so far +            return True +        BM_ROOT = force_eval(linux.LinuxVPNLauncher.BITMASK_ROOT) + +        # FIXME -- port to processProtocol +        exitCode = subprocess.call(["pkexec", +                                    BM_ROOT, "openvpn", "stop"]) +        return True if exitCode is 0 else False + +    def _kill_if_left_alive(self, tries=0): +        """ +        Check if the process is still alive, and send a +        SIGKILL after a timeout period. + +        :param tries: counter of tries, used in recursion +        :type tries: int +        """ +        while tries < self.TERMINATE_MAXTRIES: +            if self._vpnproc.transport.pid is None: +                logger.debug("Process has been happily terminated.") +                return +            else: +                logger.debug("Process did not die, waiting...") + +            tries += 1 +            reactor.callLater(self.TERMINATE_WAIT, +                              self._kill_if_left_alive, tries) +            return + +        # after running out of patience, we try a killProcess +        logger.debug("Process did not died. Sending a SIGKILL.") +        try: +            self.killit() +        except OSError: +            logger.error("Could not kill process!") + +    def killit(self): +        """ +        Sends a kill signal to the process. +        """ +        self._stop_pollers() +        if self._vpnproc is None: +            logger.debug("There's no vpn process running to kill.") +        else: +            self._vpnproc.aborted = True +            self._vpnproc.killProcess() + +    def terminate(self, shutdown=False, restart=False): +        """ +        Stops the openvpn subprocess. + +        Attempts to send a SIGTERM first, and after a timeout +        it sends a SIGKILL. + +        :param shutdown: whether this is the final shutdown +        :type shutdown: bool +        :param restart: whether this stop is part of a hard restart. +        :type restart: bool +        """ +        self._stop_pollers() + +        # First we try to be polite and send a SIGTERM... +        if self._vpnproc is not None: +            # We assume that the only valid stops are initiated +            # by an user action, not hard restarts +            self._user_stopped = not restart +            self._vpnproc.is_restart = restart + +            self._sentterm = True +            self._vpnproc.terminate_openvpn(shutdown=shutdown) + +            # ...but we also trigger a countdown to be unpolite +            # if strictly needed. +            reactor.callLater( +                self.TERMINATE_WAIT, self._kill_if_left_alive) +        else: +            logger.debug("VPN is not running.") + +    def _start_pollers(self): +        """ +        Iterate through the registered observers +        and start the looping call for them. +        """ +        for poller in self._pollers: +            poller.start(VPNManager.POLL_TIME) + +    def _stop_pollers(self): +        """ +        Iterate through the registered observers +        and stop the looping calls if they are running. +        """ +        for poller in self._pollers: +            if poller.running: +                poller.stop() +        self._pollers = [] + + +class VPNManager(object): +    """ +    This is a mixin that we use in the VPNProcess class. +    Here we get together all methods related with the openvpn management +    interface. + +    A copy of a QObject containing signals as attributes is passed along +    upon initialization, and we use that object to emit signals to qt-land. + +    For more info about management methods:: + +      zcat `dpkg -L openvpn | grep management` +    """ + +    # Timers, in secs +    # NOTE: We need to set a bigger poll time in OSX because it seems +    # openvpn malfunctions when you ask it a lot of things in a short +    # amount of time. +    POLL_TIME = 2.5 if IS_MAC else 1.0 +    CONNECTION_RETRY_TIME = 1 + +    def __init__(self, signaler=None): +        """ +        Initializes the VPNManager. + +        :param signaler: Signaler object used to send notifications to the +                         backend +        :type signaler: backend.Signaler +        """ +        self._tn = None +        self._signaler = signaler +        self._aborted = False + +    @property +    def aborted(self): +        return self._aborted + +    @aborted.setter +    def aborted(self, value): +        self._aborted = value + +    def _seek_to_eof(self): +        """ +        Read as much as available. Position seek pointer to end of stream +        """ +        try: +            self._tn.read_eager() +        except EOFError: +            logger.debug("Could not read from socket. Assuming it died.") +            return + +    def _send_command(self, command, until=b"END"): +        """ +        Sends a command to the telnet connection and reads until END +        is reached. + +        :param command: command to send +        :type command: str + +        :param until: byte delimiter string for reading command output +        :type until: byte str + +        :return: response read +        :rtype: list +        """ +        # leap_assert(self._tn, "We need a tn connection!") + +        try: +            self._tn.write("%s\n" % (command,)) +            buf = self._tn.read_until(until, 2) +            self._seek_to_eof() +            blist = buf.split('\r\n') +            if blist[-1].startswith(until): +                del blist[-1] +                return blist +            else: +                return [] + +        except socket.error: +            # XXX should get a counter and repeat only +            # after mod X times. +            logger.warning('socket error (command was: "%s")' % (command,)) +            self._close_management_socket(announce=False) +            logger.debug('trying to connect to management again') +            self.try_to_connect_to_management(max_retries=5) +            return [] + +        # XXX should move this to a errBack! +        except Exception as e: +            logger.warning("Error sending command %s: %r" % +                           (command, e)) +        return [] + +    def _close_management_socket(self, announce=True): +        """ +        Close connection to openvpn management interface. +        """ +        logger.debug('closing socket') +        if announce: +            self._tn.write("quit\n") +            self._tn.read_all() +        self._tn.get_socket().close() +        self._tn = None + +    def _connect_management(self, socket_host, socket_port): +        """ +        Connects to the management interface on the specified +        socket_host socket_port. + +        :param socket_host: either socket path (unix) or socket IP +        :type socket_host: str + +        :param socket_port: either string "unix" if it's a unix +                            socket, or port otherwise +        :type socket_port: str +        """ +        if self.is_connected(): +            self._close_management_socket() + +        try: +            self._tn = UDSTelnet(socket_host, socket_port) + +            # XXX make password optional +            # specially for win. we should generate +            # the pass on the fly when invoking manager +            # from conductor + +            # self.tn.read_until('ENTER PASSWORD:', 2) +            # self.tn.write(self.password + '\n') +            # self.tn.read_until('SUCCESS:', 2) +            if self._tn: +                self._tn.read_eager() + +        # XXX move this to the Errback +        except Exception as e: +            logger.warning("Could not connect to OpenVPN yet: %r" % (e,)) +            self._tn = None + +    def _connectCb(self, *args): +        """ +        Callback for connection. + +        :param args: not used +        """ +        if self._tn: +            logger.info('Connected to management') +        else: +            logger.debug('Cannot connect to management...') + +    def _connectErr(self, failure): +        """ +        Errorback for connection. + +        :param failure: Failure +        """ +        logger.warning(failure) + +    def connect_to_management(self, host, port): +        """ +        Connect to a management interface. + +        :param host: the host of the management interface +        :type host: str + +        :param port: the port of the management interface +        :type port: str + +        :returns: a deferred +        """ +        self.connectd = defer.maybeDeferred( +            self._connect_management, host, port) +        self.connectd.addCallbacks(self._connectCb, self._connectErr) +        return self.connectd + +    def is_connected(self): +        """ +        Returns the status of the management interface. + +        :returns: True if connected, False otherwise +        :rtype: bool +        """ +        return True if self._tn else False + +    def try_to_connect_to_management(self, retry=0, max_retries=None): +        """ +        Attempts to connect to a management interface, and retries +        after CONNECTION_RETRY_TIME if not successful. + +        :param retry: number of the retry +        :type retry: int +        """ +        if max_retries and retry > max_retries: +            logger.warning("Max retries reached while attempting to connect " +                           "to management. Aborting.") +            self.aborted = True +            return + +        # _alive flag is set in the VPNProcess class. +        if not self._alive: +            logger.debug('Tried to connect to management but process is ' +                         'not alive.') +            return +        logger.debug('trying to connect to management') +        if not self.aborted and not self.is_connected(): +            self.connect_to_management(self._socket_host, self._socket_port) +            reactor.callLater( +                self.CONNECTION_RETRY_TIME, +                self.try_to_connect_to_management, retry + 1) + +    def _parse_state_and_notify(self, output): +        """ +        Parses the output of the state command and emits state_changed +        signal when the state changes. + +        :param output: list of lines that the state command printed as +                       its output +        :type output: list +        """ +        for line in output: +            stripped = line.strip() +            if stripped == "END": +                continue +            parts = stripped.split(",") +            if len(parts) < 5: +                continue +            ts, status_step, ok, ip, remote = parts + +            state = status_step +            if state != self._last_state: +                if self._signaler is not None: +                    self._signaler.signal(self._signaler.eip_state_changed, state) +                self._last_state = state + +    def _parse_status_and_notify(self, output): +        """ +        Parses the output of the status command and emits +        status_changed signal when the status changes. + +        :param output: list of lines that the status command printed +                       as its output +        :type output: list +        """ +        tun_tap_read = "" +        tun_tap_write = "" + +        for line in output: +            stripped = line.strip() +            if stripped.endswith("STATISTICS") or stripped == "END": +                continue +            parts = stripped.split(",") +            if len(parts) < 2: +                continue + +            text, value = parts +            # text can be: +            #   "TUN/TAP read bytes" +            #   "TUN/TAP write bytes" +            #   "TCP/UDP read bytes" +            #   "TCP/UDP write bytes" +            #   "Auth read bytes" + +            if text == "TUN/TAP read bytes": +                tun_tap_read = value  # download +            elif text == "TUN/TAP write bytes": +                tun_tap_write = value  # upload + +        status = (tun_tap_read, tun_tap_write) +        if status != self._last_status: +            if self._signaler is not None: +                self._signaler.signal(self._signaler.eip_status_changed, status) +            self._last_status = status + +    def get_state(self): +        """ +        Notifies the gui of the output of the state command over +        the openvpn management interface. +        """ +        if self.is_connected(): +            return self._parse_state_and_notify(self._send_command("state")) + +    def get_status(self): +        """ +        Notifies the gui of the output of the status command over +        the openvpn management interface. +        """ +        if self.is_connected(): +            return self._parse_status_and_notify(self._send_command("status")) + +    @property +    def vpn_env(self): +        """ +        Return a dict containing the vpn environment to be used. +        """ +        return self._launcher.get_vpn_env() + +    def terminate_openvpn(self, shutdown=False): +        """ +        Attempts to terminate openvpn by sending a SIGTERM. +        """ +        if self.is_connected(): +            self._send_command("signal SIGTERM") +        if shutdown: +            self._cleanup_tempfiles() + +    def _cleanup_tempfiles(self): +        """ +        Remove all temporal files we might have left behind. + +        Iif self.port is 'unix', we have created a temporal socket path that, +        under normal circumstances, we should be able to delete. +        """ +        if self._socket_port == "unix": +            logger.debug('cleaning socket file temp folder') +            tempfolder = first(os.path.split(self._socket_host)) +            if tempfolder and os.path.isdir(tempfolder): +                try: +                    shutil.rmtree(tempfolder) +                except OSError: +                    logger.error('could not delete tmpfolder %s' % tempfolder) + +    def get_openvpn_process(self): +        """ +        Looks for openvpn instances running. + +        :rtype: process +        """ +        openvpn_process = None +        for p in psutil.process_iter(): +            try: +                # XXX Not exact! +                # Will give false positives. +                # we should check that cmdline BEGINS +                # with openvpn or with our wrapper +                # (pkexec / osascript / whatever) + +                # This needs more work, see #3268, but for the moment +                # we need to be able to filter out arguments in the form +                # --openvpn-foo, since otherwise we are shooting ourselves +                # in the feet. + +                if PSUTIL_2: +                    cmdline = p.cmdline() +                else: +                    cmdline = p.cmdline +                if any(map(lambda s: s.find( +                        "LEAPOPENVPN") != -1, cmdline)): +                    openvpn_process = p +                    break +            except psutil_AccessDenied: +                pass +        return openvpn_process + +    def stop_if_already_running(self): +        """ +        Checks if VPN is already running and tries to stop it. + +        Might raise OpenVPNAlreadyRunning. + +        :return: True if stopped, False otherwise + +        """ +        process = self.get_openvpn_process() +        if not process: +            logger.debug('Could not find openvpn process while ' +                         'trying to stop it.') +            return + +        logger.debug("OpenVPN is already running, trying to stop it...") +        cmdline = process.cmdline + +        manag_flag = "--management" +        if isinstance(cmdline, list) and manag_flag in cmdline: +            # we know that our invocation has this distinctive fragment, so +            # we use this fingerprint to tell other invocations apart. +            # this might break if we change the configuration path in the +            # launchers +            smellslikeleap = lambda s: "leap" in s and "providers" in s + +            if not any(map(smellslikeleap, cmdline)): +                logger.debug("We cannot stop this instance since we do not " +                             "recognise it as a leap invocation.") +                raise AlienOpenVPNAlreadyRunning + +            try: +                index = cmdline.index(manag_flag) +                host = cmdline[index + 1] +                port = cmdline[index + 2] +                logger.debug("Trying to connect to %s:%s" +                             % (host, port)) +                self.connect_to_management(host, port) + +                # XXX this has a problem with connections to different +                # remotes. So the reconnection will only work when we are +                # terminating instances left running for the same provider. +                # If we are killing an openvpn instance configured for another +                # provider, we will get: +                # TLS Error: local/remote TLS keys are out of sync +                # However, that should be a rare case right now. +                self._send_command("signal SIGTERM") +                self._close_management_socket(announce=True) +            except (Exception, AssertionError) as e: +                logger.warning("Problem trying to terminate OpenVPN: %r" +                               % (e,)) +        else: +            logger.debug("Could not find the expected openvpn command line.") + +        process = self.get_openvpn_process() +        if process is None: +            logger.debug("Successfully finished already running " +                         "openvpn process.") +            return True +        else: +            logger.warning("Unable to terminate OpenVPN") +            raise OpenVPNAlreadyRunning + + +class VPNProcess(protocol.ProcessProtocol, VPNManager): +    """ +    A ProcessProtocol class that can be used to spawn a process that will +    launch openvpn and connect to its management interface to control it +    programmatically. +    """ + +    def __init__(self, eipconfig, providerconfig, socket_host, socket_port, +                 signaler, openvpn_verb, remotes): +        """ +        :param eipconfig: eip configuration object +        :type eipconfig: EIPConfig + +        :param providerconfig: provider specific configuration +        :type providerconfig: ProviderConfig + +        :param socket_host: either socket path (unix) or socket IP +        :type socket_host: str + +        :param socket_port: either string "unix" if it's a unix +                            socket, or port otherwise +        :type socket_port: str + +        :param signaler: Signaler object used to receive notifications to the +                         backend +        :type signaler: backend.Signaler + +        :param openvpn_verb: the desired level of verbosity in the +                             openvpn invocation +        :type openvpn_verb: int +        """ +        VPNManager.__init__(self, signaler=signaler) +        # leap_assert(not self.isRunning(), "Starting process more than once!") + +        self._eipconfig = eipconfig +        self._providerconfig = providerconfig +        self._socket_host = socket_host +        self._socket_port = socket_port + +        self._launcher = get_vpn_launcher() + +        self._last_state = None +        self._last_status = None +        self._alive = False + +        # XXX use flags, maybe, instead of passing +        # the parameter around. +        self._openvpn_verb = openvpn_verb + +        self._vpn_observer = VPNObserver(signaler) +        self.is_restart = False + +        self._remotes = remotes + +    # processProtocol methods + +    def connectionMade(self): +        """ +        Called when the connection is made. + +        .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa +        """ +        self._alive = True +        self.aborted = False +        self.try_to_connect_to_management(max_retries=10) + +    def outReceived(self, data): +        """ +        Called when new data is available on stdout. + +        :param data: the data read on stdout + +        .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa +        """ +        # truncate the newline +        line = data[:-1] +        logger.info(line) +        self._vpn_observer.watch(line) + +    def processExited(self, reason): +        """ +        Called when the child process exits. + +        .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa +        """ +        exit_code = reason.value.exitCode +        if isinstance(exit_code, int): +            logger.debug("processExited, status %d" % (exit_code,)) +        if self._signaler is not None: +            self._signaler.signal( +                self._signaler.eip_process_finished, exit_code) +        self._alive = False + +    def processEnded(self, reason): +        """ +        Called when the child process exits and all file descriptors associated +        with it have been closed. + +        .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa +        """ +        exit_code = reason.value.exitCode +        if isinstance(exit_code, int): +            logger.debug("processEnded, status %d" % (exit_code,)) + +    # polling + +    def pollStatus(self): +        """ +        Polls connection status. +        """ +        if self._alive: +            self.get_status() + +    def pollState(self): +        """ +        Polls connection state. +        """ +        if self._alive: +            self.get_state() + +    # launcher + +    def getCommand(self): +        """ +        Gets the vpn command from the aproppriate launcher. + +        Might throw: +            VPNLauncherException, +            OpenVPNNotFoundException. + +        :rtype: list of str +        """ +        print self._remotes +        command = self._launcher.get_vpn_command( +            eipconfig=self._eipconfig, +            providerconfig=self._providerconfig, +            socket_host=self._socket_host, +            socket_port=self._socket_port, +            openvpn_verb=self._openvpn_verb, +            remotes=self._remotes) + +        encoding = sys.getfilesystemencoding() +        for i, c in enumerate(command): +            if not isinstance(c, str): +                command[i] = c.encode(encoding) + +        logger.debug("Running VPN with command: ") +        logger.debug("{0}".format(" ".join(command))) +        return command + +    def getGateways(self): +        """ +        Get the gateways from the appropiate launcher. + +        :rtype: list +        """ +        gateways_ports = self._launcher.get_gateways( +            self._eipconfig, self._providerconfig) + +        # filter out ports since we don't need that info +        return [gateway for gateway, port in gateways_ports] + +    # shutdown + +    def killProcess(self): +        """ +        Sends the KILL signal to the running process. +        """ +        try: +            self.transport.signalProcess('KILL') +        except internet_error.ProcessExitedAlready: +            logger.debug('Process Exited Already') diff --git a/src/leap/bitmask/vpn/service.py b/src/leap/bitmask/vpn/service.py new file mode 100644 index 00000000..965d68d1 --- /dev/null +++ b/src/leap/bitmask/vpn/service.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# service.py +# Copyright (C) 2015-2017 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/>. + +""" +EIP service declaration. +""" + +import os + +from twisted.application import service +from twisted.python import log + +from leap.bitmask.hooks import HookableService +from leap.bitmask.vpn import EIPManager +from leap.bitmask.vpn.utils import get_path_prefix + + +class EIPService(HookableService): + +    def __init__(self, basepath=None): +        """ +        Initialize EIP service +        """ +        super(EIPService, self).__init__() + +        self._started = False + +        if basepath is None: +            self._basepath = get_path_prefix() +        else: +            self._basepath = basepath + +    def _setup(self, provider): +        """ +        Set up EIPManager for a specified provider. + +        :param provider: the provider to use, e.g. 'demo.bitmask.net' +        :type provider: str +        """ +        # FIXME +        # XXX picked manually from eip-service.json +        remotes = ( +            ("198.252.153.84", "1194"), +            ("46.165.242.169", "1194"), +        ) + +        prefix = os.path.join(self._basepath, +                              "leap/providers/{0}/keys".format(provider)) +        cert_path = key_path = prefix + "/client/openvpn.pem" +        ca_path = prefix + "/ca/cacert.pem" + +        # FIXME +        # XXX picked manually from eip-service.json +        extra_flags = { +            "auth": "SHA1", +            "cipher": "AES-128-CBC", +            "keepalive": "10 30", +            "tls-cipher": "DHE-RSA-AES128-SHA", +            "tun-ipv6": "true", +        } + +        self._eip = EIPManager(remotes, cert_path, key_path, ca_path, +                               extra_flags) + +    def startService(self): +        print "Starting EIP Service..." +        super(EIPService, self).startService() + +    def stopService(self): +        print "Stopping EIP Service..." +        super(EIPService, self).stopService() + +    def do_start(self, domain): +        self._setup(domain) +        self._eip.start() +        self._started = True +        return "Starting" + +    def do_stop(self): +        if self._started: +            self._eip.stop() +            self._started = False +            return "Stopping" +        else: +            return "Not started" diff --git a/src/leap/bitmask/vpn/statusqueue.py b/src/leap/bitmask/vpn/statusqueue.py new file mode 100644 index 00000000..ff7f3111 --- /dev/null +++ b/src/leap/bitmask/vpn/statusqueue.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Queue used to store status changes on EIP/VPN/Firewall and to be checked for +any app using this vpn library. + +This should be considered a temporary code meant to replace the signaling +system that announces events inside of vpn code and is catched on the bitmask +client. +""" + +import Queue + + +class StatusQueue(object): +    def __init__(self): +        self._status = Queue.Queue() + +        # this attributes serve to simulate events in the old signaler used +        self.eip_network_unreachable = "network_unreachable" +        self.eip_process_restart_tls = "process_restart_tls" +        self.eip_process_restart_ping = "process_restart_ping" +        self.eip_connected = "initialization_completed" +        self.eip_status_changed = "status_changed"  # has parameter +        self.eip_state_changed = "state_changed"  # has parameter +        self.eip_process_finished = "process_finished"  # has parameter + +    def get_noblock(self): +        s = None +        try: +            s = self._status.get(False) +        except Queue.Empty: +            pass + +        return s + +    def get(self): +        return self._status.get(timeout=1) + +    def signal(self, status, data=None): +        self._status.put({'status': status, 'data': data}) diff --git a/src/leap/bitmask/vpn/udstelnet.py b/src/leap/bitmask/vpn/udstelnet.py new file mode 100644 index 00000000..e6c82350 --- /dev/null +++ b/src/leap/bitmask/vpn/udstelnet.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# udstelnet.py +# Copyright (C) 2013 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/>. + +import os +import socket +import telnetlib + + +class ConnectionRefusedError(Exception): +    pass + + +class MissingSocketError(Exception): +    pass + + +class UDSTelnet(telnetlib.Telnet): +    """ +    A telnet-alike class, that can listen on unix domain sockets +    """ + +    def open(self, host, port=23, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): +        """ +        Connect to a host. If port is 'unix', it will open a +        connection over unix docmain sockets. + +        The optional second argument is the port number, which +        defaults to the standard telnet port (23). +        Don't try to reopen an already connected instance. +        """ +        self.eof = 0 +        self.host = host +        self.port = port +        self.timeout = timeout + +        if self.port == "unix": +            # unix sockets spoken +            if not os.path.exists(self.host): +                raise MissingSocketError() +            self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +            try: +                self.sock.connect(self.host) +            except socket.error: +                raise ConnectionRefusedError() +        else: +            self.sock = socket.create_connection((host, port), timeout) diff --git a/src/leap/bitmask/vpn/utils.py b/src/leap/bitmask/vpn/utils.py new file mode 100644 index 00000000..51f24d9b --- /dev/null +++ b/src/leap/bitmask/vpn/utils.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# util.py +# Copyright (C) 2013-2017 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/>. + +""" +Common utils +""" + +import os + + +def get_path_prefix(standalone=False): +    """ +    Returns the platform dependent path prefix. + +    :param standalone: if True it will return the prefix for a standalone +                       application. +                       Otherwise, it will return the system default for +                       configuration storage. +    :type standalone: bool +    """ +    return os.path.expanduser("~/.config")  # hardcoded Linux XDG config path + +    # TODO: this is to use XDG specifications +    # commented temporarily to avoid that extra dependency + +    # config_home = get_xdg_config_home() +    # if standalone: +    #     config_home = os.path.join(os.getcwd(), "config") +    # +    # return config_home + + +def force_eval(items): +    """ +    Return a sequence that evaluates any callable in the sequence, +    instantiating it beforehand if the item is a class, and +    leaves the non-callable items without change. +    """ +    def do_eval(thing): +        if isinstance(thing, type): +            return thing()() +        if callable(thing): +            return thing() +        return thing + +    if isinstance(items, (list, tuple)): +        return map(do_eval, items) +    else: +        return do_eval(items) + + +def first(things): +    """ +    Return the head of a collection. + +    :param things: a sequence to extract the head from. +    :type things: sequence +    :return: object, or None +    """ +    try: +        return things[0] +    except (IndexError, TypeError): +        return None + + +def get_vpn_launcher(): +    """ +    Return the VPN launcher for the current platform. +    """ +    from leap.bitmask.vpn.constants import IS_LINUX, IS_MAC, IS_WIN + +    if not (IS_LINUX or IS_MAC or IS_WIN): +        error_msg = "VPN Launcher not implemented for this platform." +        raise NotImplementedError(error_msg) + +    launcher = None +    if IS_LINUX: +        from leap.bitmask.vpn.launchers.linux import LinuxVPNLauncher +        launcher = LinuxVPNLauncher +    elif IS_MAC: +        from leap.bitmask.vpn.launchers.darwin import DarwinVPNLauncher +        launcher = DarwinVPNLauncher +    elif IS_WIN: +        from leap.bitmask.vpn.launchers.windows import WindowsVPNLauncher +        launcher = WindowsVPNLauncher + +    if launcher is None: +        raise Exception("Launcher is None") + +    return launcher() | 
