diff options
Diffstat (limited to 'src')
-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 51a100d..692ec06 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 c3e97f7..a34bf0e 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 0000000..6c3cf06 --- /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 0000000..c7a5147 --- /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 0000000..cfd8b59 --- /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 0000000..77cf1dc --- /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 0000000..e69de29 --- /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 0000000..80ac12e --- /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 0000000..4335b8e --- /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 0000000..c049596 --- /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 0000000..e69de29 --- /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 0000000..f19404c --- /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 0000000..a86dcb4 --- /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 0000000..bfaac2f --- /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 0000000..2403327 --- /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 0000000..e8ed557 --- /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 0000000..05847e2 --- /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 0000000..965d68d --- /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 0000000..ff7f311 --- /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 0000000..e6c8235 --- /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 0000000..51f24d9 --- /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() |