From e1103904fbdd9b54b53075956c279271c17e9a8f Mon Sep 17 00:00:00 2001 From: antialias Date: Mon, 13 Aug 2012 22:53:40 -0700 Subject: First (non-working) pass at abstracting exisiting functionality into OO framework. --- src/leap/Authentication.py | 11 ++ src/leap/Configuration.py | 11 ++ src/leap/Connection.py | 129 +++++++++++++ src/leap/EIPConnection.py | 270 ++++++++++++++++++++++++++++ src/leap/OpenVPNConnection.py | 408 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 829 insertions(+) create mode 100644 src/leap/Authentication.py create mode 100644 src/leap/Configuration.py create mode 100644 src/leap/Connection.py create mode 100644 src/leap/EIPConnection.py create mode 100644 src/leap/OpenVPNConnection.py (limited to 'src') diff --git a/src/leap/Authentication.py b/src/leap/Authentication.py new file mode 100644 index 00000000..0bd54fd6 --- /dev/null +++ b/src/leap/Authentication.py @@ -0,0 +1,11 @@ +""" +Authentication Base Class +""" + +class Authentication(object): + """ + I have no idea how Authentication (certs,?) will be done, but stub it here. + """ + pass + + diff --git a/src/leap/Configuration.py b/src/leap/Configuration.py new file mode 100644 index 00000000..b0ab2bf2 --- /dev/null +++ b/src/leap/Configuration.py @@ -0,0 +1,11 @@ +""" +Configuration Base Class +""" + +class Configuration(object): + """ + I have no idea how configuration (txt vs. sqlite) will be done, but let's stub it now. + """ + pass + + diff --git a/src/leap/Connection.py b/src/leap/Connection.py new file mode 100644 index 00000000..6534560b --- /dev/null +++ b/src/leap/Connection.py @@ -0,0 +1,129 @@ +""" +Base Connection Classs +""" +from __future__ import (division, unicode_literals, print_function) +#import threading +from functools import partial +import logging + +from leap.utils.coroutines import spawn_and_watch_process +from leap.baseapp.config import get_config, get_vpn_stdout_mockup +from leap.eip.vpnwatcher import EIPConnectionStatus, status_watcher +from leap.eip.vpnmanager import OpenVPNManager, ConnectionRefusedError + +from leap.Configuration import Configuration +from leap.Authentication import Authentication + +logger = logging.getLogger(name=__name__) + +class Connection(Configuration, Authentication): + def __init__(self, *args, **kwargs): + self.connection_state = None + self.desired_connection_state = None + super(Connection, self).__init__(*args, **kwargs) + + def connect(self): + """ + entry point for connection process + """ + pass + + def disconnect(self): + """ + disconnects client + """ + pass + + def shutdown(self): + """ + shutdown and quit + """ + self.desired_con_state = self.status.DISCONNECTED + + def connection_state(self): + """ + returns the current connection state + """ + return self.status.current + + def desired_connection_state(self): + """ + returns the desired_connection state + """ + return self.desired_connection_state + + def poll_connection_state(self): + """ + """ + try: + state = self.get_connection_state() + except ConnectionRefusedError: + # connection refused. might be not ready yet. + return + if not state: + return + (ts, status_step, + ok, ip, remote) = state + self.status.set_vpn_state(status_step) + status_step = self.status.get_readable_status() + return (ts, status_step, ok, ip, remote) + + def get_icon_name(self): + """ + get icon name from status object + """ + return self.status.get_state_icon() + + # + # private methods + # + + def _disconnect(self): + """ + private method for disconnecting + """ + if self.subp is not None: + self.subp.terminate() + self.subp = None + # XXX signal state changes! :) + + def _is_alive(self): + """ + don't know yet + """ + pass + + def _connect(self): + """ + entry point for connection cascade methods. + """ + #conn_result = ConState.DISCONNECTED + try: + conn_result = self._try_connection() + except UnrecoverableError as except_msg: + logger.error("FATAL: %s" % unicode(except_msg)) + conn_result = self.status.UNRECOVERABLE + except Exception as except_msg: + self.error_queue.append(except_msg) + logger.error("Failed Connection: %s" % + unicode(except_msg)) + return conn_result + + + +class ConnectionError(Exception): + """ + generic connection error + """ + def __str__(self): + if len(self.args) >= 1: + return repr(self.args[0]) + else: + raise self() + + +class UnrecoverableError(ConnectionError): + """ + we cannot do anything about it, sorry + """ + pass diff --git a/src/leap/EIPConnection.py b/src/leap/EIPConnection.py new file mode 100644 index 00000000..f16f01f5 --- /dev/null +++ b/src/leap/EIPConnection.py @@ -0,0 +1,270 @@ +""" +EIP Connection Class +""" + +from leap.OpenVPNConnection import OpenVPNConnection, MissingSocketError, ConnectionRefusedError +from leap.Connection import ConnectionError + +class EIPConnection(OpenVPNConnection): + """ + Manages the execution of the OpenVPN process, auto starts, monitors the + network connection, handles configuration, fixes leaky hosts, handles + errors, etc. + Preferences will be stored via the Storage API. (TBD) + Status updates (connected, bandwidth, etc) are signaled to the GUI. + """ + + def __init__(self, *args, **kwargs): + self.settingsfile = kwargs.get('settingsfile', None) + self.logfile = kwargs.get('logfile', None) + self.error_queue = [] + self.desired_con_state = None # ??? + + status_signals = kwargs.pop('status_signals', None) + self.status = EIPConnectionStatus(callbacks=status_signals) + + super(EIPConnection, self).__init__(*args, **kwargs) + + def connect(self): + """ + entry point for connection process + """ + self.forget_errors() + self._try_connection() + # XXX should capture errors? + + def disconnect(self): + """ + disconnects client + """ + self._disconnect() + self.status.change_to(self.status.DISCONNECTED) + pass + + def shutdown(self): + """ + shutdown and quit + """ + self.desired_con_state = self.status.DISCONNECTED + + def connection_state(self): + """ + returns the current connection state + """ + return self.status.current + + def desired_connection_state(self): + """ + returns the desired_connection state + """ + return self.desired_con_state + + def poll_connection_state(self): + """ + """ + try: + state = self.get_connection_state() + except ConnectionRefusedError: + # connection refused. might be not ready yet. + return + if not state: + return + (ts, status_step, + ok, ip, remote) = state + self.status.set_vpn_state(status_step) + status_step = self.status.get_readable_status() + return (ts, status_step, ok, ip, remote) + + def get_icon_name(self): + """ + get icon name from status object + """ + return self.status.get_state_icon() + + # + # private methods + # + + def _disconnect(self): + """ + private method for disconnecting + """ + if self.subp is not None: + self.subp.terminate() + self.subp = None + # XXX signal state changes! :) + + def _is_alive(self): + """ + don't know yet + """ + pass + + def _connect(self): + """ + entry point for connection cascade methods. + """ + #conn_result = ConState.DISCONNECTED + try: + conn_result = self._try_connection() + except UnrecoverableError as except_msg: + logger.error("FATAL: %s" % unicode(except_msg)) + conn_result = self.status.UNRECOVERABLE + except Exception as except_msg: + self.error_queue.append(except_msg) + logger.error("Failed Connection: %s" % + unicode(except_msg)) + return conn_result + +"""generic watcher object that keeps track of connection status""" +# This should be deprecated in favor of daemon mode + management +# interface. But we can leave it here for debug purposes. + + +class EIPConnectionStatus(object): + """ + Keep track of client (gui) and openvpn + states. + + These are the OpenVPN states: + CONNECTING -- OpenVPN's initial state. + WAIT -- (Client only) Waiting for initial response + from server. + AUTH -- (Client only) Authenticating with server. + GET_CONFIG -- (Client only) Downloading configuration options + from server. + ASSIGN_IP -- Assigning IP address to virtual network + interface. + ADD_ROUTES -- Adding routes to system. + CONNECTED -- Initialization Sequence Completed. + RECONNECTING -- A restart has occurred. + EXITING -- A graceful exit is in progress. + + We add some extra states: + + DISCONNECTED -- GUI initial state. + UNRECOVERABLE -- An unrecoverable error has been raised + while invoking openvpn service. + """ + CONNECTING = 1 + WAIT = 2 + AUTH = 3 + GET_CONFIG = 4 + ASSIGN_IP = 5 + ADD_ROUTES = 6 + CONNECTED = 7 + RECONNECTING = 8 + EXITING = 9 + + # gui specific states: + UNRECOVERABLE = 11 + DISCONNECTED = 0 + + def __init__(self, callbacks=None): + """ + EIPConnectionStatus is initialized with a tuple + of signals to be triggered. + :param callbacks: a tuple of (callable) observers + :type callbacks: tuple + """ + # (callbacks to connect to signals in Qt-land) + self.current = self.DISCONNECTED + self.previous = None + self.callbacks = callbacks + + def get_readable_status(self): + # XXX DRY status / labels a little bit. + # think we'll want to i18n this. + human_status = { + 0: 'disconnected', + 1: 'connecting', + 2: 'waiting', + 3: 'authenticating', + 4: 'getting config', + 5: 'assigning ip', + 6: 'adding routes', + 7: 'connected', + 8: 'reconnecting', + 9: 'exiting', + 11: 'unrecoverable error', + } + return human_status[self.current] + + def get_state_icon(self): + """ + returns the high level icon + for each fine-grain openvpn state + """ + connecting = (self.CONNECTING, + self.WAIT, + self.AUTH, + self.GET_CONFIG, + self.ASSIGN_IP, + self.ADD_ROUTES) + connected = (self.CONNECTED,) + disconnected = (self.DISCONNECTED, + self.UNRECOVERABLE) + + # this can be made smarter, + # but it's like it'll change, + # so +readability. + + if self.current in connecting: + return "connecting" + if self.current in connected: + return "connected" + if self.current in disconnected: + return "disconnected" + + def set_vpn_state(self, status): + """ + accepts a state string from the management + interface, and sets the internal state. + :param status: openvpn STATE (uppercase). + :type status: str + """ + if hasattr(self, status): + self.change_to(getattr(self, status)) + + def set_current(self, to): + """ + setter for the 'current' property + :param to: destination state + :type to: int + """ + self.current = to + + def change_to(self, to): + """ + :param to: destination state + :type to: int + """ + if to == self.current: + return + changed = False + from_ = self.current + self.current = to + + # We can add transition restrictions + # here to ensure no transitions are + # allowed outside the fsm. + + self.set_current(to) + changed = True + + #trigger signals (as callbacks) + #print('current state: %s' % self.current) + if changed: + self.previous = from_ + if self.callbacks: + for cb in self.callbacks: + if callable(cb): + cb(self) + + + +class EIPClientError(ConnectionError): + """ + base EIPClient Exception + """ + pass diff --git a/src/leap/OpenVPNConnection.py b/src/leap/OpenVPNConnection.py new file mode 100644 index 00000000..a26059a7 --- /dev/null +++ b/src/leap/OpenVPNConnection.py @@ -0,0 +1,408 @@ +""" +OpenVPN Connection +""" + +from __future__ import (print_function) +import logging +import os +import socket +import telnetlib +import time +from functools import partial + +logger = logging.getLogger(name=__name__) + +from leap.utils.coroutines import spawn_and_watch_process +from leap.baseapp.config import get_config +from leap.Connection import Connection + +class OpenVPNConnection(Connection): + """ + All related to invocation + of the openvpn binary + """ + # Connection Methods + + def __init__(self, config_file=None, watcher_cb=None,host="/tmp/.eip.sock", port="unix", password=None): + #XXX FIXME + #change watcher_cb to line_observer + """ + :param config_file: configuration file to read from + :param watcher_cb: callback to be \ +called for each line in watched stdout + :param signal_map: dictionary of signal names and callables \ +to be triggered for each one of them. + :type config_file: str + :type watcher_cb: function + :type signal_map: dict + """ + + self.config_file = config_file + self.watcher_cb = watcher_cb + #self.signal_maps = signal_maps + + self.subp = None + self.watcher = None + + self.server = None + self.port = None + self.proto = None + + self.autostart = True + + self._get_config() + + #Get this info from the Configuration Class + #XXX hardcoded host here. change. + self.host = host + if isinstance(port, str) and port.isdigit(): + port = int(port) + self.port = port + self.password = password + self.tn = None + + #XXX workaround for signaling + #the ui that we don't know how to + #manage a connection error + self.with_errors = False + + + def _set_command_mockup(self): + """ + sets command and args for a command mockup + that just mimics the output from the real thing + """ + command, args = get_vpn_stdout_mockup() + self.command, self.args = command, args + + def _get_config(self): + """ + retrieves the config options from defaults or + home file, or config file passed in command line. + """ + config = get_config(config_file=self.config_file) + self.config = config + + if config.has_option('openvpn', 'command'): + commandline = config.get('openvpn', 'command') + if commandline == "mockup": + self._set_command_mockup() + return + command_split = commandline.split(' ') + command = command_split[0] + if len(command_split) > 1: + args = command_split[1:] + else: + args = [] + self.command = command + #print("debug: command = %s" % command) + self.args = args + else: + self._set_command_mockup() + + if config.has_option('openvpn', 'autostart'): + autostart = config.get('openvpn', 'autostart') + self.autostart = autostart + + def _launch_openvpn(self): + """ + invocation of openvpn binaries in a subprocess. + """ + #XXX TODO: + #deprecate watcher_cb, + #use _only_ signal_maps instead + + if self.watcher_cb is not None: + linewrite_callback = self.watcher_cb + else: + #XXX get logger instead + linewrite_callback = lambda line: print('watcher: %s' % line) + + observers = (linewrite_callback, + partial(self.status_watcher, self.status)) + subp, watcher = spawn_and_watch_process( + self.command, + self.args, + observers=observers) + self.subp = subp + self.watcher = watcher + + conn_result = self.status.CONNECTED + return conn_result + + def _try_connection(self): + """ + attempts to connect + """ + if self.subp is not None: + print('cowardly refusing to launch subprocess again') + return + self._launch_openvpn() + + def cleanup(self): + """ + terminates child subprocess + """ + if self.subp: + self.subp.terminate() + + + #Here are the actual code to manage OpenVPN Connection + #TODO: Look into abstraction them and moving them up into base class + # this code based on code from cube-routed project + + """ + Run commands over OpenVPN management interface + and parses the output. + """ + # XXX might need a lock to avoid + # race conditions here... + + def forget_errors(self): + print('forgetting errors') + self.with_errors = False + + def connect(self): + """Connect to openvpn management interface""" + try: + self.close() + except: + #XXX don't like this general + #catch here. + pass + if self.connected(): + return True + self.tn = UDSTelnet(self.host, self.port) + + # XXX make password optional + # specially for win plat. 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) + + self._seek_to_eof() + self.forget_errors() + return True + + def _seek_to_eof(self): + """ + Read as much as available. Position seek pointer to end of stream + """ + b = self.tn.read_eager() + while b: + b = self.tn.read_eager() + + def connected(self): + """ + Returns True if connected + rtype: bool + """ + #return bool(getattr(self, 'tn', None)) + try: + assert self.tn + return True + except: + #XXX get rid of + #this pokemon exception!!! + return False + + def close(self, announce=True): + """ + Close connection to openvpn management interface + """ + if announce: + self.tn.write("quit\n") + self.tn.read_all() + self.tn.get_socket().close() + del self.tn + + def _send_command(self, cmd, tries=0): + """ + Send a command to openvpn and return response as list + """ + if tries > 3: + return [] + if not self.connected(): + try: + self.connect() + except MissingSocketError: + #XXX capture more helpful error + #messages + #pass + return self.make_error() + try: + self.tn.write(cmd + "\n") + except socket.error: + logger.error('socket error') + print('socket error!') + self.close(announce=False) + self._send_command(cmd, tries=tries + 1) + return [] + buf = self.tn.read_until(b"END", 2) + self._seek_to_eof() + blist = buf.split('\r\n') + if blist[-1].startswith('END'): + del blist[-1] + return blist + else: + return [] + + def _send_short_command(self, cmd): + """ + parse output from commands that are + delimited by "success" instead + """ + if not self.connected(): + self.connect() + self.tn.write(cmd + "\n") + # XXX not working? + buf = self.tn.read_until(b"SUCCESS", 2) + self._seek_to_eof() + blist = buf.split('\r\n') + return blist + + # + # useful vpn commands + # + + def pid(self): + #XXX broken + return self._send_short_command("pid") + + def make_error(self): + """ + capture error and wrap it in an + understandable format + """ + #XXX get helpful error codes + self.with_errors = True + now = int(time.time()) + return '%s,LAUNCHER ERROR,ERROR,-,-' % now + + def state(self): + """ + OpenVPN command: state + """ + state = self._send_command("state") + if not state: + return None + if isinstance(state, str): + return state + if isinstance(state, list): + if len(state) == 1: + return state[0] + else: + return state[-1] + + def status(self): + """ + OpenVPN command: status + """ + status = self._send_command("status") + return status + + def status2(self): + """ + OpenVPN command: last 2 statuses + """ + return self._send_command("status 2") + + # + # parse info + # + + def get_status_io(self): + status = self.status() + if isinstance(status, str): + lines = status.split('\n') + if isinstance(status, list): + lines = status + try: + (header, when, tun_read, tun_write, + tcp_read, tcp_write, auth_read) = tuple(lines) + except ValueError: + return None + + when_ts = time.strptime(when.split(',')[1], "%a %b %d %H:%M:%S %Y") + sep = ',' + # XXX cleanup! + tun_read = tun_read.split(sep)[1] + tun_write = tun_write.split(sep)[1] + tcp_read = tcp_read.split(sep)[1] + tcp_write = tcp_write.split(sep)[1] + auth_read = auth_read.split(sep)[1] + + # XXX this could be a named tuple. prettier. + return when_ts, (tun_read, tun_write, tcp_read, tcp_write, auth_read) + + def get_connection_state(self): + state = self.state() + if state is not None: + ts, status_step, ok, ip, remote = state.split(',') + ts = time.gmtime(float(ts)) + # XXX this could be a named tuple. prettier. + return ts, status_step, ok, ip, remote + + def status_watcher(self, cs, line): + """ + a wrapper that calls to ConnectionStatus object + :param cs: a EIPConnectionStatus instance + :type cs: EIPConnectionStatus object + :param line: a single line of the watched output + :type line: str + """ + #print('status watcher watching') + + # from the mullvad code, should watch for + # things like: + # "Initialization Sequence Completed" + # "With Errors" + # "Tap-Win32" + + if "Completed" in line: + cs.change_to(cs.CONNECTED) + return + + if "Initial packet from" in line: + cs.change_to(cs.CONNECTING) + return + + + +class MissingSocketError(Exception): + pass + + +class ConnectionRefusedError(Exception): + pass + +class UDSTelnet(telnetlib.Telnet): + + 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) -- cgit v1.2.3