From 6d1d18faec5caa60c26b8245f0ab17c63d0b80d8 Mon Sep 17 00:00:00 2001 From: "Kali Kaneko (leap communications)" Date: Wed, 1 Feb 2017 00:00:20 +0100 Subject: [refactor] split vpn control into some modules --- src/leap/bitmask/vpn/_config.py | 34 ++ src/leap/bitmask/vpn/_control.py | 198 ++++++++++ src/leap/bitmask/vpn/_management.py | 422 ++++++++++++++++++++++ src/leap/bitmask/vpn/_observer.py | 73 ++++ src/leap/bitmask/vpn/manager.py | 43 +-- src/leap/bitmask/vpn/process.py | 698 +----------------------------------- 6 files changed, 735 insertions(+), 733 deletions(-) create mode 100644 src/leap/bitmask/vpn/_config.py create mode 100644 src/leap/bitmask/vpn/_control.py create mode 100644 src/leap/bitmask/vpn/_management.py create mode 100644 src/leap/bitmask/vpn/_observer.py diff --git a/src/leap/bitmask/vpn/_config.py b/src/leap/bitmask/vpn/_config.py new file mode 100644 index 00000000..7dfabf7d --- /dev/null +++ b/src/leap/bitmask/vpn/_config.py @@ -0,0 +1,34 @@ + +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 + diff --git a/src/leap/bitmask/vpn/_control.py b/src/leap/bitmask/vpn/_control.py new file mode 100644 index 00000000..991dc0ff --- /dev/null +++ b/src/leap/bitmask/vpn/_control.py @@ -0,0 +1,198 @@ +class VPNControl(object): + """ + This is the high-level object that the service 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() + + + # TODO -- rename to stop ?? + 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.") + + + # TODO should this be public?? + 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 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 _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 = [] diff --git a/src/leap/bitmask/vpn/_management.py b/src/leap/bitmask/vpn/_management.py new file mode 100644 index 00000000..0eb37b7b --- /dev/null +++ b/src/leap/bitmask/vpn/_management.py @@ -0,0 +1,422 @@ +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 VPNManagement(object): + """ + This is a mixin that we use in the VPNProcess class. + Here we get together all methods related with the openvpn management + interface. + + 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 + diff --git a/src/leap/bitmask/vpn/_observer.py b/src/leap/bitmask/vpn/_observer.py new file mode 100644 index 00000000..1bb363d6 --- /dev/null +++ b/src/leap/bitmask/vpn/_observer.py @@ -0,0 +1,73 @@ +from twisted.logger import Logger + +logger = Logger() + + +class VPNObserver(object): + """ + A class containing different patterns in the openvpn output that + we can react upon. + """ + + _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()) + diff --git a/src/leap/bitmask/vpn/manager.py b/src/leap/bitmask/vpn/manager.py index 24033273..541da9e0 100644 --- a/src/leap/bitmask/vpn/manager.py +++ b/src/leap/bitmask/vpn/manager.py @@ -22,43 +22,10 @@ VPN Manager import os import tempfile -from leap.bitmask.vpn import process +from leap.bitmask.vpn import process, _config 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): @@ -78,8 +45,8 @@ class VPNManager(object): domain = "demo.bitmask.net" self._remotes = remotes - self._eipconfig = _TempEIPConfig(extra_flags, cert_path, ports) - self._providerconfig = _TempProviderConfig(domain, ca_path) + self._eipconfig = _config._TempEIPConfig(extra_flags, cert_path, ports) + self._providerconfig = _config._TempProviderConfig(domain, ca_path) # signaler = None # XXX handle signaling somehow... signaler = mock_signaler self._vpn = process.VPN(remotes=remotes, signaler=signaler) @@ -93,7 +60,7 @@ class VPNManager(object): * gateway, port * domain name """ - host, port = self._get_management() + host, port = self._get_management_location() # TODO need gateways here # sorting them doesn't belong in here @@ -140,7 +107,7 @@ class VPNManager(object): """ pass - def _get_management(self): + def _get_management_location(self): """ Return a tuple with the host (socket) and port to be used for VPN. diff --git a/src/leap/bitmask/vpn/process.py b/src/leap/bitmask/vpn/process.py index 05847e21..4aae26b5 100644 --- a/src/leap/bitmask/vpn/process.py +++ b/src/leap/bitmask/vpn/process.py @@ -47,6 +47,7 @@ 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 +from leap.bitmask.vpn import _observer logger = Logger() @@ -55,697 +56,6 @@ logger = Logger() 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 @@ -777,8 +87,7 @@ class VPNProcess(protocol.ProcessProtocol, VPNManager): openvpn invocation :type openvpn_verb: int """ - VPNManager.__init__(self, signaler=signaler) - # leap_assert(not self.isRunning(), "Starting process more than once!") + VPNManagement.__init__(self, signaler=signaler) self._eipconfig = eipconfig self._providerconfig = providerconfig @@ -795,7 +104,7 @@ class VPNProcess(protocol.ProcessProtocol, VPNManager): # the parameter around. self._openvpn_verb = openvpn_verb - self._vpn_observer = VPNObserver(signaler) + self._vpn_observer = _observer.VPNObserver(signaler) self.is_restart = False self._remotes = remotes @@ -878,7 +187,6 @@ class VPNProcess(protocol.ProcessProtocol, VPNManager): :rtype: list of str """ - print self._remotes command = self._launcher.get_vpn_command( eipconfig=self._eipconfig, providerconfig=self._providerconfig, -- cgit v1.2.3