summaryrefslogtreecommitdiff
path: root/src/leap
diff options
context:
space:
mode:
authorKali Kaneko (leap communications) <kali@leap.se>2017-02-01 00:00:20 +0100
committerKali Kaneko (leap communications) <kali@leap.se>2017-02-23 00:37:29 +0100
commit6d1d18faec5caa60c26b8245f0ab17c63d0b80d8 (patch)
tree08dbfd0e257bea0434543df6505bcc9e12acbf97 /src/leap
parent8fb76310e14a5893397894c6155ac7aa4f28e483 (diff)
[refactor] split vpn control into some modules
Diffstat (limited to 'src/leap')
-rw-r--r--src/leap/bitmask/vpn/_config.py34
-rw-r--r--src/leap/bitmask/vpn/_control.py198
-rw-r--r--src/leap/bitmask/vpn/_management.py422
-rw-r--r--src/leap/bitmask/vpn/_observer.py73
-rw-r--r--src/leap/bitmask/vpn/manager.py43
-rw-r--r--src/leap/bitmask/vpn/process.py698
6 files changed, 735 insertions, 733 deletions
diff --git a/src/leap/bitmask/vpn/_config.py b/src/leap/bitmask/vpn/_config.py
new file mode 100644
index 0000000..7dfabf7
--- /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 0000000..991dc0f
--- /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 0000000..0eb37b7
--- /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 0000000..1bb363d
--- /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 2403327..541da9e 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 05847e2..4aae26b 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,