summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKali Kaneko (leap communications) <kali@leap.se>2017-01-31 13:31:13 +0100
committerKali Kaneko (leap communications) <kali@leap.se>2017-02-23 00:37:25 +0100
commitca0e1c4518749e27bccad817d22ab87afbf8acf7 (patch)
tree636c1188683c1ea91d70b3aecd2810aafa7cf724
parentff5ec25029db7669163854886be254fccde90e80 (diff)
[feature] initial port of legacy vpn code
non functional at the moment, but started doing some cleanup
-rw-r--r--src/leap/bitmask/core/configurable.py2
-rw-r--r--src/leap/bitmask/core/service.py2
-rw-r--r--src/leap/bitmask/vpn/__init__.py10
-rw-r--r--src/leap/bitmask/vpn/constants.py28
-rw-r--r--src/leap/bitmask/vpn/eip.py87
-rw-r--r--src/leap/bitmask/vpn/errors.py14
-rw-r--r--src/leap/bitmask/vpn/fw/__init__.py0
-rwxr-xr-xsrc/leap/bitmask/vpn/fw/bitmask-root971
-rw-r--r--src/leap/bitmask/vpn/fw/firewall.py95
-rw-r--r--src/leap/bitmask/vpn/launcher.py379
-rw-r--r--src/leap/bitmask/vpn/launchers/__init__.py0
-rw-r--r--src/leap/bitmask/vpn/launchers/darwin.py199
-rw-r--r--src/leap/bitmask/vpn/launchers/linux.py181
-rw-r--r--src/leap/bitmask/vpn/launchers/windows.py73
-rw-r--r--src/leap/bitmask/vpn/manager.py160
-rw-r--r--src/leap/bitmask/vpn/privilege.py210
-rw-r--r--src/leap/bitmask/vpn/process.py920
-rw-r--r--src/leap/bitmask/vpn/service.py100
-rw-r--r--src/leap/bitmask/vpn/statusqueue.py42
-rw-r--r--src/leap/bitmask/vpn/udstelnet.py60
-rw-r--r--src/leap/bitmask/vpn/utils.py104
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()