def __init__(self): self._tp = GlobalThreadPool() self._logger = Logger() self._ingress = queue.Queue() self._egress = queue.Queue() # TODO: Put the following in a configuration file. self._home_dir = '/home' self._user_dir = os.path.join(self._home_dir, '{username}') self._ssh_dir = os.path.join(self._user_dir, '.ssh') self._authkeys = os.path.join(self._ssh_dir, 'authorized_keys') # Start the action handler t = Task(target=self.__action_handler, infinite=True) t.failure = self.__catch_fail self._action_handler_task = self._tp.run(t)
def __init__(self): cfg = GlobalConfigStore() self._tp = GlobalThreadPool() self._username = cfg.agent_username self._agent_key = cfg.agentkey self._backend_addr = (cfg.host, cfg.port) self._backend_hostkey = cfg.backend_hostkey self._logger = Logger() self._endpoints = [] self._tx = queue.Queue() self._conn_handler_task = None self._connected = False self._running = False self._client = None self._chan = None
class Processor(object): """A class to handle action messages coming from the backend and send back a feedback to indicate success or failure of the action requested. This class is a kind-singleton which means you cannot instantiate more than one copy per application life time. """ __metaclass__ = KindSingletonMeta def __init__(self): self._tp = GlobalThreadPool() self._logger = Logger() self._ingress = queue.Queue() self._egress = queue.Queue() # TODO: Put the following in a configuration file. self._home_dir = '/home' self._user_dir = os.path.join(self._home_dir, '{username}') self._ssh_dir = os.path.join(self._user_dir, '.ssh') self._authkeys = os.path.join(self._ssh_dir, 'authorized_keys') # Start the action handler t = Task(target=self.__action_handler, infinite=True) t.failure = self.__catch_fail self._action_handler_task = self._tp.run(t) def endpoint(self): """Return an ingress and an egress points to communicate with this processor. :returns: :class:`bastio.ssh.client.BackendConnector.EndPoint` """ return BackendConnector.EndPoint(ingress=self._ingress, egress=self._egress) def process(self, message): """Process a message and return a feedback. :param message: A message to be processed. :type message: A subclass of :class:`bastio.ssh.protocol.ActionMessage` :returns: :class:`bastio.ssh.protocol.FeedbackMessage` """ if isinstance(message, AddUserMessage): # Add a user if one doesn't exist feedback = self._add_user(message) elif isinstance(message, RemoveUserMessage): # Remove a user if one exists feedback = self._remove_user(message) elif isinstance(message, UpdateUserMessage): # Update a user either to give it root access or to demote it feedback = self._update_user(message) elif isinstance(message, AddKeyMessage): # Add public key to the user's authorized_keys file feedback = self._add_key(message) elif isinstance(message, RemoveKeyMessage): # Remove public key from the user's authorized_keys file feedback = self._remove_key(message) else: # NOTE: This execution branch must never be reached, # do not take this lightly if it happens. feedback = message.reply( ("internal error: agent does not know how to handle messages" " of type `{type}`").format(type=message.type), FeedbackMessage.ERROR) return feedback def stop(self): """Signal the action handler to stop.""" self._action_handler_task.stop() def __action_handler(self, kill_ev): self._logger.warning("action handler started") while not kill_ev.is_set(): message = self._get_ingress(timeout=3) if message: feedback = self.process(message) self._put_egress(feedback) def __catch_fail(self, failure): try: raise failure.exception, failure.message, failure.traceback except Exception: self._logger.critical("unexpected error occurred in the action handler", exc_info=True) def _get_ingress(self, timeout): try: return self._ingress.get(timeout=timeout) except queue.Empty: return None def _put_egress(self, item): self._egress.put(item) ### ### BEGIN COMMAND METHODS ### def _chk_user(self, message, status=FeedbackMessage.ERROR, should_exist=False): # Check if a user exists user_exist = os.path.exists(self._user_dir.format( username=message.username)) try: pwd.getpwnam(message.username) except KeyError: user_exist = False if user_exist: reply_msg = "{username} already exists".format(username=message.username) if should_exist: # user exists and should return False else: # user exists but shouldn't feedback = message.reply(reply_msg, status) else: reply_msg = "{username} does not exist".format(username=message.username) if should_exist: # user doesn't exist but should feedback = message.reply(reply_msg, status) else: # user doesn't exist and shouldn't return False return feedback def _chk_key(self, message): # Check if a public key exists try: with open(self._authkeys.format(username=message.username), 'rb') as fd: auth_data = fd.read() except Exception: return False return message.public_key in auth_data def _create_ssh(self, message): # Make sure that .ssh exists and has the right permissions try: os.mkdir(self._ssh_dir.format(username=message.username), 0700) except OSError: pass # Directory already exists (or perm denied... very unlikely) # Touch .ssh/authorized_keys file auth_file = self._authkeys.format(username=message.username) try: with open(auth_file, 'ab') as fd: pass # We just want to create the file if it doesn't exist except IOError: # We can't do anything about it here, it will be handled by other messages pass # Make sure that .ssh/authorized_keys file has the right permissions try: os.chmod(auth_file, 0600) except OSError: # We can't do anything about it here, offload it to future messages pass # Chown .ssh/authorized_keys to the user try: pw_struct = pwd.getpwnam(message.username) os.chown(self._ssh_dir.format(username=message.username), pw_struct.pw_uid, pw_struct.pw_gid) os.chown(auth_file, pw_struct.pw_uid, pw_struct.pw_gid) except KeyError: # username not found from getpwnam pass except OSError: # chown failed pass def _add_user(self, message): # Add user if message.sudo: add_command = 'useradd -mU -G sudo {username}' else: add_command = 'useradd -mU {username}' add_command = add_command.format(username=message.username) # Check if a user exists feedback = self._chk_user(message, FeedbackMessage.INFO, False) if feedback: self._create_ssh(message) return feedback # Create the user _, stderr = self._run_command(add_command) if stderr: feedback = message.reply(stderr, FeedbackMessage.ERROR) return feedback # Clear out user's password _, stderr = self._run_command("passwd -d {username}".format( username=message.username)) if stderr: feedback = message.reply(stderr, FeedbackMessage.ERROR) return feedback self._create_ssh(message) feedback = message.reply("{username} was created successfully".format( username=message.username), FeedbackMessage.SUCCESS) return feedback def _remove_user(self, message): # Remove user rm_command = 'userdel -r {username}'.format(username=message.username) # Check if a user exists feedback = self._chk_user(message, FeedbackMessage.INFO, True) if feedback: return feedback # Try to remove the user _, stderr = self._run_command(rm_command) if stderr: feedback = message.reply(stderr, FeedbackMessage.ERROR) else: feedback = message.reply( "{username} was removed successfully".format( username=message.username), FeedbackMessage.SUCCESS) return feedback def _update_user(self, message): # Update user flag = '-a' if message.sudo else '-d' update_command = 'gpasswd {flag} {username} sudo'.format(flag=flag, username=message.username) # Check if a user exists feedback = self._chk_user(message, FeedbackMessage.ERROR, True) if feedback: return feedback # Update a user either to give it root access or to demote it _, stderr = self._run_command(update_command) if stderr: feedback = message.reply(stderr, FeedbackMessage.ERROR) else: if message.sudo: fb_str = '{username} was added to the sudo group successfully' else: fb_str = '{username} was removed from the sudo group successfully' feedback = message.reply(fb_str.format(username=message.username), FeedbackMessage.SUCCESS) return feedback def _add_key(self, message): # Add public key pubkey = message.public_key username = message.username # Check if a user exists feedback = self._chk_user(message, FeedbackMessage.ERROR, True) if feedback: return feedback # Check if public key already exists if self._chk_key(message): feedback = message.reply( "public key `{pub_key}` for {username} already exists".format( pub_key=pubkey, username=username), FeedbackMessage.INFO) return feedback # Try to add the public key to the user's authorized_keys file auth_file = self._authkeys.format(username=username) try: with open(auth_file, 'ab') as fd: fd.write(pubkey + '\n') feedback = message.reply( "added public key to {username} successfully".format( username=username), FeedbackMessage.SUCCESS) except IOError as ex: feedback = message.reply(ex.strerror, FeedbackMessage.ERROR) except Exception as ex: feedback = message.reply(ex.message, FeedbackMessage.ERROR) return feedback def _remove_key(self, message): # Remove public key pubkey = message.public_key username = message.username # Check if a user exists feedback = self._chk_user(message, FeedbackMessage.ERROR, True) if feedback: return feedback # Check if public key does not exist if not self._chk_key(message): feedback = message.reply( "public key for {username} does not exist".format( username=username), FeedbackMessage.INFO) return feedback # Try to remove the public key from the user's authorized_keys file auth_data = [] auth_file = self._authkeys.format(username=username) try: with open(auth_file, 'rb') as fd: for line in fd.readlines(): if pubkey in line: continue auth_data.append(line) with open(auth_file, 'wb') as fd: # TODO: A race condition is possible here where the file could # be written to before we write to it and therefore overriding # the changes made to it by some other application. Find a fix # for it. This is quite unlikely in this particular case so # don't sweat it. fd.writelines(auth_data) feedback = message.reply( "removed public key from {username} successfully".format( username=username), FeedbackMessage.SUCCESS) except IOError as ex: feedback = message.reply(ex.strerror, FeedbackMessage.ERROR) except Exception as ex: feedback = message.reply(ex.message, FeedbackMessage.ERROR) return feedback @staticmethod def _run_command(command, input_data=None): try: po = subprocess.Popen(args=command, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = po.communicate(input_data) except OSError as ex: stderr = ex.strerror except ValueError as ex: stderr = ex.message return stdout, stderr
class BackendConnector(object): """A singleton to establish and maintain a secure connection with the backend over a specific subsystem channel. This connector supports registering of endpoints where processors can register their endpoint to communicate with the backend. It is guaranteed that the messages will be delivered ASAP but the actual ETA is chaotic. """ __metaclass__ = KindSingletonMeta EndPoint = collections.namedtuple("EndPoint", "ingress egress") Subsystem = 'bastio-agent' def __init__(self): cfg = GlobalConfigStore() self._tp = GlobalThreadPool() self._username = cfg.agent_username self._agent_key = cfg.agentkey self._backend_addr = (cfg.host, cfg.port) self._backend_hostkey = cfg.backend_hostkey self._logger = Logger() self._endpoints = [] self._tx = queue.Queue() self._conn_handler_task = None self._connected = False self._running = False self._client = None self._chan = None def start(self): """Start the connection handler thread.""" if not self._running: self._running = True t = Task(target=self.__conn_handler, infinite=True) t.failure = self._catch_fail self._conn_handler_task = self._tp.run(t) def stop(self): """Stop the connection handler thread.""" if self._running: self._running = False self.close() self._conn_handler_task.stop() def register(self, endpoint): """Register an endpoint to this connector to so that it can communicate with the backend. The endpoint is a tuple of one ingress queue as first argument and egress as the second argument. :param endpoint: A tuple of two queues; ingress and egress. :type endpoint: :class:`BackendConnector.EndPoint` """ self._endpoints.append(endpoint) def is_active(self): """Check whether the transport is still active.""" if self._client: t = self._client.get_transport() if t: return t.is_active() return False def close(self): """Close open channels and transport.""" if self._chan: self._chan.close() if self._client: self._client.close() self._connected = False self._logger.critical("connection lost with the backend") def __conn_handler(self, kill_ev): self._logger.warning("backend connection handler started") while not kill_ev.is_set(): # Try to connect to the backend try: self._connect() except BastioBackendError as ex: self.close() self._logger.critical(ex.message) # TODO: Implement a more decent reconnection strategy time.sleep(5) # Sleep 5 seconds before retrial continue # Read a message from the wire, parse it, and push it to ingress queue(s) try: json_string = self._read_message() message = MessageParser.parse(json_string) self._put_ingress(message) except socket.timeout: pass # No messages are ready to be read except BastioNetstringError as ex: self._logger.critical( "error parsing a Netstring message: {}".format(ex.message)) self.close() continue except BastioMessageError as ex: self._logger.critical( "error parsing a protocol message: {}".format(ex.message)) self.close() continue except BastioEOFError: self._logger.critical("received EOF on channel") self.close() continue # Get an item from the egress queue(s) and send it to the backend try: message = self._get_egress(timeout=0.01) # 10ms if message == None: # No message is available to send continue self._write_message(message.to_json()) except socket.timeout: # Too many un-ACK'd packets? Sliding window shut on our fingers? # We don't really know what happened, lets reschedule the last # message for retransmission anyway self._push_queue(self._tx, message) except BastioEOFError: # Message was not sent because channel was closed # re-push the message to the TX queue again and retry connection self._push_queue(self._tx, message) self.close() continue def _connect(self): """An idempotent method to connect to the backend.""" try: if self._connected: return # Prepare host keys self._client = paramiko.SSHClient() hostkeys = self._client.get_host_keys() hostkey_server_name = self._make_hostkey_entry_name(self._backend_addr) hostkeys.add(hostkey_server_name, self._backend_hostkey.get_name(), self._backend_hostkey) # Try to connect self._client.connect(hostname=self._backend_addr[0], port=self._backend_addr[1], username=self._username, pkey=self._agent_key, allow_agent=False, look_for_keys=False) self._connected = True # Open session and establish the subsystem self._chan = self._invoke_bastio() self._logger.critical("connection established with the backend") except BastioBackendError: raise except paramiko.AuthenticationException: reraise(BastioBackendError, "authentication with backend failed") except paramiko.BadHostKeyException: reraise(BastioBackendError, "backend host key does not match") except socket.error as ex: reraise(BastioBackendError, ex.strerror.lower()) except Exception: reraise(BastioBackendError) def _invoke_bastio(self): """Start a bastio subsystem on an already authenticated transport. :returns: A channel connected to the subsystem or None. """ if not self.is_active(): raise BastioBackendError("client is not connected") t = self._client.get_transport() chan = t.open_session() if not chan: raise BastioBackendError("opening a session with the backend failed") chan.settimeout(0.01) # 10ms chan.invoke_subsystem(self.Subsystem) return chan def _read_message(self): nets = Netstring(self._chan) return nets.recv() def _write_message(self, data): nets = Netstring.compose(data) remaining = len(nets) while remaining > 0: n = self._chan.send(nets) if n <= 0: raise BastioEOFError("channel closed") remaining -= n def _put_ingress(self, item): for endpoint in self._endpoints: endpoint.ingress.put(item) def _get_egress(self, timeout): for endpoint in self._endpoints: try: item = endpoint.egress.get_nowait() self._tx.put(item) except queue.Empty: pass try: return self._tx.get(timeout=timeout) except queue.Empty: return None @staticmethod def _catch_fail(failure): log = Logger() try: raise failure.exception, failure.message, failure.traceback except: msg = 'unexpected error occurred: {}'.format(failure.message) log.critical(msg, exc_info=True) @staticmethod def _make_hostkey_entry_name(addr): """We do the following to work around a paramiko inconsistency.""" if addr[1] == paramiko.config.SSH_PORT: return addr[0] return '[{}]:{}'.format(*addr)