Exemple #1
0
	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port forwarding
		with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25)
		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				remote_server,
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
		except paramiko.SSHException as error:
			self.logger.warning("failed with ssh exception '{0}'".format(error.message))
		except Exception:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
		else:
			self.smtp_server = self._ssh_forwarder.local_server
			return ConnectionErrorReason.SUCCESS
		return ConnectionErrorReason.ERROR_UNKNOWN
Exemple #2
0
    def server_ssh_connect(self):
        """
		Connect to the remote SMTP server over SSH and configure port
		forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP
		traffic.

		:return: The connection status.
		:rtype: bool
		"""
        server = utilities.server_parse(self.config['ssh_server'], 22)
        username = self.config['ssh_username']
        password = self.config['ssh_password']
        remote_server = utilities.server_parse(self.config['smtp_server'], 25)
        local_port = random.randint(2000, 6000)
        try:
            self.ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                local_port,
                remote_server,
                preferred_private_key=self.config.get('ssh_preferred_key'))
            self.ssh_forwarder.start()
            time.sleep(0.5)
        except:
            self.logger.warning('failed to connect to remote ssh server')
            return False
        self.smtp_server = ('localhost', local_port)
        return True
Exemple #3
0
    def server_ssh_connect(self):
        """
		Connect to the remote SMTP server over SSH and configure port
		forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP
		traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
        server = parse_server(self.config['ssh_server'], 22)
        username = self.config['ssh_username']
        password = self.config['ssh_password']
        remote_server = parse_server(self.config['smtp_server'], 25)
        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                remote_server,
                preferred_private_key=self.config.get('ssh_preferred_key'))
            self._ssh_forwarder.start()
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
        except Exception:
            self.logger.warning('failed to connect to the remote ssh server',
                                exc_info=True)
            return ConnectionErrorReason.ERROR_UNKNOWN
        self.logger.info(
            "started ssh port forwarding to the remote smtp server ({0})".
            format(str(self._ssh_forwarder)))
        self.smtp_server = self._ssh_forwarder.local_server
        return ConnectionErrorReason.SUCCESS
    def _create_ssh_forwarder(self, server, username, password):
        """
		Create and set the
		:py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
        active_window = self.get_active_window()
        title_ssh_error = 'Failed To Connect To The SSH Service'
        server_remote_port = self.config['server_remote_port']

        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password, ('127.0.0.1', server_remote_port),
                private_key=self.config.get('ssh_preferred_key'),
                missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(
                    self))
            self._ssh_forwarder.start()
        except errors.KingPhisherAbortError as error:
            self.logger.info("ssh connection aborted ({0})".format(
                error.message))
        except paramiko.PasswordRequiredException:
            gui_utilities.show_dialog_error(
                title_ssh_error, active_window,
                'The specified SSH key requires a password.')
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            gui_utilities.show_dialog_error(
                title_ssh_error, active_window,
                'The server responded that the credentials are invalid.')
        except paramiko.SSHException as error:
            self.logger.warning("failed with ssh exception '{0}'".format(
                error.args[0]))
        except socket.error as error:
            gui_utilities.show_dialog_exc_socket_error(error,
                                                       active_window,
                                                       title=title_ssh_error)
        except Exception as error:
            self.logger.warning('failed to connect to the remote ssh server',
                                exc_info=True)
            gui_utilities.show_dialog_error(
                title_ssh_error, active_window,
                "An {0}.{1} error occurred.".format(error.__class__.__module__,
                                                    error.__class__.__name__))
        else:
            return self._ssh_forwarder.local_server
        self.emit('server-disconnected')
        return
Exemple #5
0
	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port forwarding
		with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		server = parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = parse_server(self.config['smtp_server'], 25)
		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				remote_server,
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
		except paramiko.SSHException as error:
			self.logger.warning("failed with ssh exception '{0}'".format(error.message))
		except Exception:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
		else:
			self.smtp_server = self._ssh_forwarder.local_server
			return ConnectionErrorReason.SUCCESS
		return ConnectionErrorReason.ERROR_UNKNOWN
	def _create_ssh_forwarder(self, server, username, password):
		"""
		Create and set the
		:py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
		active_window = self.get_active_window()
		title_ssh_error = 'Failed To Connect To The SSH Service'
		server_remote_port = self.config['server_remote_port']
		local_port = random.randint(2000, 6000)

		try:
			self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key'])
			self._ssh_forwarder.start()
			time.sleep(0.5)
			self.logger.info('started ssh port forwarding')
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.')
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error)
		except Exception as error:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
			gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__))
		else:
			return local_port
		self.server_disconnect()
		return
Exemple #7
0
    def _create_ssh_forwarder(self, server, username, password):
        """
		Create and set the :py:attr:`~.KingPhisherClient._ssh_forwarder`
		attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
        title_ssh_error = 'Failed To Connect To The SSH Service'
        server_remote_port = self.config['server_remote_port']
        local_port = random.randint(2000, 6000)

        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                local_port, ('127.0.0.1', server_remote_port),
                preferred_private_key=self.config['ssh_preferred_key'])
            self._ssh_forwarder.start()
            time.sleep(0.5)
            self.logger.info('started ssh port forwarding')
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            gui_utilities.show_dialog_error(
                title_ssh_error, self,
                'The server responded that the credentials are invalid.')
        except socket.error as error:
            gui_utilities.show_dialog_exc_socket_error(error,
                                                       self,
                                                       title=title_ssh_error)
        except Exception as error:
            self.logger.warning('failed to connect to the remote ssh server',
                                exc_info=True)
            gui_utilities.show_dialog_error(
                title_ssh_error, self,
                "An {0}.{1} error occurred.".format(error.__class__.__module__,
                                                    error.__class__.__name__))
        else:
            return local_port
        self.server_disconnect()
        return
	def server_ssh_connect(self):
		server = utilities.server_parse(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = utilities.server_parse(self.config['smtp_server'], 25)
		local_port = random.randint(2000, 6000)
		try:
			self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key'))
			self.ssh_forwarder.start()
			time.sleep(0.5)
		except:
			self.logger.warning('failed to connect to remote ssh server')
			return False
		self.smtp_server = ('localhost', local_port)
		return True
	def _create_ssh_forwarder(self, server, username, password):
		"""
		Create and set the
		:py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
		active_window = self.get_active_window()
		title_ssh_error = 'Failed To Connect To The SSH Service'
		server_remote_port = self.config['server_remote_port']

		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				('127.0.0.1', server_remote_port),
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.PasswordRequiredException:
			gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The specified SSH key requires a password.')
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.')
		except paramiko.SSHException as error:
			self.logger.warning("failed with ssh exception '{0}'".format(error.args[0]))
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error)
		except Exception as error:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
			gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__))
		else:
			return self._ssh_forwarder.local_server
		self.emit('server-disconnected')
		return
Exemple #10
0
	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port
		forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP
		traffic.

		:return: The connection status.
		:rtype: bool
		"""
		server = parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = parse_server(self.config['smtp_server'], 25)
		local_port = random.randint(2000, 6000)
		try:
			self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key'))
			self._ssh_forwarder.start()
			time.sleep(0.5)
		except Exception:
			self.logger.warning('failed to connect to remote ssh server', exc_info=True)
			return False
		self.smtp_server = ('localhost', local_port)
		return True
class KingPhisherClientApplication(_Gtk_Application):
	"""
	This is the top level King Phisher client object. It contains the
	custom GObject signals, keeps all the GUI references, and manages
	the RPC client object. This is also the parent window for most
	GTK objects.

	:GObject Signals: :ref:`gobject-signals-application-label`
	"""
	# pylint: disable=too-many-public-methods
	__gsignals__ = {
		'campaign-changed': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
		'campaign-created': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
		'campaign-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (str,)),
		'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
		'config-load': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (bool,)),
		'config-save': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
		'credential-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object,)),
		'exit': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
		'exit-confirm': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
		'message-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object,)),
		'message-sent': (GObject.SIGNAL_RUN_FIRST, None, (str, str)),
		'reload-css-style': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
		'rpc-cache-clear': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
		'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ()),
		'server-disconnected': (GObject.SIGNAL_RUN_FIRST, None, ()),
		'sftp-client-start': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
		'visit-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object,)),
	}

	def __init__(self, config_file=None, use_plugins=True, use_style=True):
		super(KingPhisherClientApplication, self).__init__()
		if use_style:
			self._theme_file = 'theme.css'
		else:
			self._theme_file = DISABLED
		self.logger = logging.getLogger('KingPhisher.Client.Application')
		# log version information for debugging purposes
		self.logger.debug("gi.repository GLib version: {0}".format('.'.join(map(str, GLib.glib_version))))
		self.logger.debug("gi.repository GObject version: {0}".format('.'.join(map(str, GObject.pygobject_version))))
		self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format(Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()))
		if rpc_terminal.has_vte:
			self.logger.debug("gi.repository VTE version: {0}".format(rpc_terminal.Vte._version))
		if graphs.has_matplotlib:
			self.logger.debug("matplotlib version: {0}".format(graphs.matplotlib.__version__))
		self.set_property('application-id', 'org.king-phisher.client')
		self.set_property('register-session', True)
		self.config_file = config_file or os.path.join(USER_DATA_PATH, 'config.json')
		"""The file containing the King Phisher client configuration."""
		if not os.path.isfile(self.config_file):
			self._create_config()
		self.config = None
		"""The primary King Phisher client configuration."""
		self.main_window = None
		"""The primary top-level :py:class:`~.MainAppWindow` instance."""
		self.rpc = None
		"""The :py:class:`~.KingPhisherRPCClient` instance for the application."""
		self._ssh_forwarder = None
		"""The SSH forwarder responsible for tunneling RPC communications."""
		self.style_provider = None
		try:
			self.emit('config-load', True)
		except IOError:
			self.logger.critical('failed to load the client configuration')
			raise

		self.connect('window-added', self.signal_window_added)
		self.actions = {}
		self._create_actions()

		if not use_plugins:
			self.logger.info('disabling all plugins')
			self.config['plugins.enabled'] = []
		self.plugin_manager = plugins.ClientPluginManager(
			[os.path.join(USER_DATA_PATH, 'plugins'), find.find_data_directory('plugins')],
			self
		)
		if use_plugins:
			self.plugin_manager.load_all()

	def _create_actions(self):
		action = Gio.SimpleAction.new('emit-application-signal', GLib.VariantType.new('s'))
		action.connect('activate', self.action_emit_application_signal)
		accelerators = (
			('<Control><Shift>F1', 'rpc-cache-clear'),
			('<Control><Shift>F2', 'config-save'),
			('<Control><Shift>F12', 'reload-css-style')
		)
		for key, signal_name in accelerators:
			if Gtk.check_version(3, 14, 0):
				self.add_accelerator(key, 'win.emit-application-signal', GLib.Variant.new_string(signal_name))
			else:
				self.set_accels_for_action("win.emit-application-signal('{0}')".format(signal_name), (key,))
		self.actions['emit-application-signal'] = action
		self.add_action(action)

	def _create_ssh_forwarder(self, server, username, password):
		"""
		Create and set the
		:py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
		active_window = self.get_active_window()
		title_ssh_error = 'Failed To Connect To The SSH Service'
		server_remote_port = self.config['server_remote_port']

		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				('127.0.0.1', server_remote_port),
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.PasswordRequiredException:
			gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The specified SSH key requires a password.')
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.')
		except paramiko.SSHException as error:
			self.logger.warning("failed with ssh exception '{0}'".format(error.args[0]))
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error)
		except Exception as error:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
			gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__))
		else:
			return self._ssh_forwarder.local_server
		self.emit('server-disconnected')
		return

	def _create_config(self):
		config_dir = os.path.dirname(self.config_file)
		if not os.path.isdir(config_dir):
			self.logger.debug('creating the user configuration directory')
			os.makedirs(config_dir)
		# move the pre 1.0.0 config file if it exists
		old_path = os.path.expanduser('~/.king_phisher.json')
		if os.path.isfile(old_path) and os.access(old_path, os.R_OK):
			self.logger.debug('moving the old config file to the new location')
			os.rename(old_path, self.config_file)
		else:
			client_template = find.find_data_file('client_config.json')
			shutil.copy(client_template, self.config_file)

	def campaign_configure(self):
		assistant = assistants.CampaignAssistant(self, campaign_id=self.config['campaign_id'])
		assistant.assistant.set_transient_for(self.get_active_window())
		assistant.assistant.set_modal(True)

		# do this to keep a reference to prevent garbage collection
		attr_name = '_tmpref_campaign_assistant'
		setattr(self, attr_name, assistant)
		assistant.assistant.connect('destroy', lambda widget: delattr(self, attr_name))

		assistant.interact()

	def do_campaign_delete(self, campaign_id):
		"""
		Delete the campaign on the server. A confirmation dialog will be
		displayed before the operation is performed. If the campaign is deleted
		and a new campaign is not selected with
		:py:meth:`.show_campaign_selection`, the client will quit.
		"""
		self.rpc('db/table/delete', 'campaigns', campaign_id)
		if campaign_id == self.config['campaign_id'] and not self.show_campaign_selection():
			gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'A campaign must be selected.')
			self.quit()

	def do_credential_delete(self, row_ids):
		if len(row_ids) == 1:
			self.rpc('db/table/delete', 'credentials', row_ids[0])
		else:
			self.rpc('db/table/delete/multi', 'credentials', row_ids)

	def do_message_delete(self, row_ids):
		if len(row_ids) == 1:
			self.rpc('db/table/delete', 'messages', row_ids[0])
		else:
			self.rpc('db/table/delete/multi', 'messages', row_ids)

	def do_visit_delete(self, row_ids):
		if len(row_ids) == 1:
			self.rpc('db/table/delete', 'visits', row_ids[0])
		else:
			self.rpc('db/table/delete/multi', 'visits', row_ids)

	def campaign_rename(self):
		"""
		Show a dialog prompting the user to for the a new name to assign to the
		currently selected campaign.
		"""
		campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id'])
		prompt = dialogs.TextEntryDialog.build_prompt(self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name)
		response = prompt.interact()
		if response == None or response == campaign.name:
			return
		self.rpc('db/table/set', 'campaigns', self.config['campaign_id'], 'name', response)
		gui_utilities.show_dialog_info('Campaign Name Updated', self.get_active_window(), 'The campaign name was successfully changed.')

	def exception_hook(self, exc_type, exc_value, exc_traceback):
		if isinstance(exc_value, KeyboardInterrupt):
			self.logger.warning('received a KeyboardInterrupt exception')
			return
		exc_info = (exc_type, exc_value, exc_traceback)
		error_uid = str(uuid.uuid4())
		self.logger.error("error uid: {0} an unhandled exception was thrown".format(error_uid), exc_info=exc_info)
		dialogs.ExceptionDialog(self, exc_info=exc_info, error_uid=error_uid).interact()

	def quit(self, optional=False):
		"""
		Quit the client and perform any necessary clean up operations. If
		*optional* is False then the exit-confirm signal will not be sent and
		there will not be any opportunities for the client to cancel the
		operation.

		:param bool optional: Whether the quit is request is optional or not.
		"""
		self.emit('exit-confirm' if optional else 'exit')

	def action_emit_application_signal(self, _, signal_name):
		signal_name = signal_name.get_string()
		self.logger.debug('action emit-application-signal invoked for ' + signal_name)
		self.emit(signal_name)

	def do_activate(self):
		Gtk.Application.do_activate(self)
		sys.excepthook = self.exception_hook

		# reset theme settings to defaults so we have a standard baseline
		settings = Gtk.Settings.get_default()
		if settings.get_property('gtk-theme-name') != GTK3_DEFAULT_THEME:
			self.logger.debug('resetting the gtk-theme-name property to it\'s default value')
			settings.set_property('gtk-theme-name', GTK3_DEFAULT_THEME)
		if settings.get_property('gtk-icon-theme-name') != GTK3_DEFAULT_THEME:
			self.logger.debug('resetting the gtk-icon-theme-name property to it\'s default value')
			settings.set_property('gtk-icon-theme-name', GTK3_DEFAULT_THEME)
		settings.set_property('gtk-application-prefer-dark-theme', False)

		# load a custom css theme file if one is available
		theme_file = self.theme_file
		if theme_file:
			self.style_provider = self.load_style_css(theme_file)
		elif theme_file is DISABLED:
			self.logger.debug('no css theme file will be loaded (styling has been disabled)')
		else:
			self.logger.debug('no css theme file will be loaded (file not found)')

		# create and show the main window
		self.main_window = main.MainAppWindow(self.config, self)
		self.main_window.set_position(Gtk.WindowPosition.CENTER)
		self.main_window.show()

		for name in list(self.config['plugins.enabled']):
			try:
				self.plugin_manager.load(name)
				self.plugin_manager.enable(name)
			except Exception:
				self.config['plugins.enabled'].remove(name)
				gui_utilities.show_dialog_error(
					'Failed To Enable Plugin',
					self.main_window,
					"Plugin '{0}' could not be enabled.".format(name)
				)

	def do_campaign_set(self, campaign_id):
		self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id']))
		self.emit('rpc-cache-clear')

	def do_config_save(self):
		self.logger.info('writing the client configuration to disk')
		config = copy.copy(self.config)
		for key in self.config.keys():
			if 'password' in key or key == 'server_config':
				del config[key]
		with open(os.path.expanduser(self.config_file), 'w') as config_file_h:
			json_ex.dump(config, config_file_h)

	def do_exit(self):
		self.plugin_manager.shutdown()

		self.main_window.hide()
		gui_utilities.gtk_widget_destroy_children(self.main_window)
		gui_utilities.gtk_sync()
		self.emit('server-disconnected')
		self.main_window.destroy()
		return

	def do_exit_confirm(self):
		self.emit('exit')

	def do_reload_css_style(self):
		if self.style_provider:
			Gtk.StyleContext.remove_provider_for_screen(
				Gdk.Screen.get_default(),
				self.style_provider
			)
			self.style_provider = None
		theme_file = self.theme_file
		if theme_file:
			self.style_provider = self.load_style_css(theme_file)

	def do_rpc_cache_clear(self):
		if self.rpc:
			self.rpc.cache_clear()

	def do_server_connected(self):
		self.load_server_config()
		campaign_id = self.config.get('campaign_id')
		if not campaign_id:
			if not self.show_campaign_selection():
				self.logger.debug('no campaign selected, disconnecting and exiting')
				self.emit('exit')
				return True
		campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True)
		if campaign_info is None:
			if not self.show_campaign_selection():
				self.logger.debug('no campaign selected, disconnecting and exiting')
				self.emit('exit')
				return True
			campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True)
		self.config['campaign_name'] = campaign_info.name
		self.emit('campaign-set', self.config['campaign_id'])
		return

	def do_shutdown(self):
		Gtk.Application.do_shutdown(self)
		sys.excepthook = sys.__excepthook__
		self.emit('config-save')

	@property
	def theme_file(self):
		if not self._theme_file:
			return DISABLED
		return find.find_data_file(os.path.join('style', self._theme_file))

	def do_config_load(self, load_defaults):
		"""
		Load the client configuration from disk and set the
		:py:attr:`~.KingPhisherClientApplication.config` attribute.

		:param bool load_defaults: Load missing options from the template configuration file.
		"""
		self.logger.info('loading the config from disk')
		client_template = find.find_data_file('client_config.json')
		config_file = os.path.expanduser(self.config_file)
		with open(config_file, 'r') as tmp_file:
			self.config = json_ex.load(tmp_file)
		if load_defaults:
			with open(client_template, 'r') as tmp_file:
				client_template = json_ex.load(tmp_file)
			for key, value in client_template.items():
				if not key in self.config:
					self.config[key] = value

	def merge_config(self, config_file, strict=True):
		"""
		Merge the configuration information from the specified configuration
		file. Only keys which exist in the currently loaded configuration are
		copied over while non-existent keys are skipped. The contents of the new
		configuration overwrites the existing.

		:param bool strict: Do not try remove trailing commas from the JSON data.
		:param str config_file: The path to the configuration file to merge.
		"""
		with open(config_file, 'r') as tmp_file:
			config = json_ex.load(tmp_file, strict=strict)
		if not isinstance(config, dict):
			self.logger.error("can not merge configuration file: {0} (invalid format)".format(config_file))
			return
		self.logger.debug('merging configuration information from source file: ' + config_file)
		for key, value in config.items():
			if not key in self.config:
				self.logger.warning("skipped merging non-existent configuration key {0}".format(key))
				continue
			self.config[key] = value
		return

	def load_server_config(self):
		"""Load the necessary values from the server's configuration."""
		self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root'])
		return

	def load_style_css(self, css_file):
		self.logger.debug('loading style from css file: ' + css_file)
		css_file = Gio.File.new_for_path(css_file)
		style_provider = Gtk.CssProvider()
		style_provider.connect('parsing-error', self.signal_css_provider_parsing_error)
		try:
			style_provider.load_from_file(css_file)
		except GLib.Error:  # pylint: disable=catching-non-exception
			self.logger.error('there was an error parsing the css file, it will not be applied as a style provider')
			return None
		Gtk.StyleContext.add_provider_for_screen(
			Gdk.Screen.get_default(),
			style_provider,
			Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
		)
		return style_provider

	def server_connect(self, username, password, otp=None):
		# pylint: disable=too-many-locals
		server_version_info = None
		title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'
		active_window = self.get_active_window()

		server = parse_server(self.config['server'], 22)
		if ipaddress.is_loopback(server[0]):
			local_server = ('localhost', self.config['server_remote_port'])
			self.logger.info("connecting to local king phisher instance")
		else:
			local_server = self._create_ssh_forwarder(server, username, password)
		if not local_server:
			return False, ConnectionErrorReason.ERROR_PORT_FORWARD

		rpc = client_rpc.KingPhisherRPCClient(local_server, use_ssl=self.config.get('server_use_ssl'))
		if self.config.get('rpc.serializer'):
			try:
				rpc.set_serializer(self.config['rpc.serializer'])
			except ValueError as error:
				self.logger.error("failed to set the rpc serializer, error: '{0}'".format(error.message))

		generic_message = 'Can not contact the RPC HTTP service, ensure that the '
		generic_message += "King Phisher Server is currently running on port {0}.".format(int(self.config['server_remote_port']))
		connection_failed = True
		try:
			server_version_info = rpc('version')
			assert server_version_info is not None
		except AdvancedHTTPServerRPCError as error:
			self.logger.warning('failed to connect to the remote rpc service due to http status: ' + str(error.status))
			gui_utilities.show_dialog_error(title_rpc_error, active_window, "The server responded with HTTP status: {0}.".format(str(error.status)))
		except BadStatusLine as error:
			self.logger.warning('failed to connect to the remote rpc service due to http bad status line: ' + error.line)
			gui_utilities.show_dialog_error(title_rpc_error, active_window, generic_message)
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, active_window)
		except ssl.CertificateError as error:
			self.logger.warning('failed to connect to the remote rpc service with a https certificate error: ' + error.message)
			gui_utilities.show_dialog_error(title_rpc_error, active_window, 'The server presented an invalid SSL certificate.')
		except Exception:
			self.logger.warning('failed to connect to the remote rpc service', exc_info=True)
			gui_utilities.show_dialog_error(title_rpc_error, active_window, generic_message)
		else:
			connection_failed = False

		if connection_failed:
			self.emit('server-disconnected')
			return False, ConnectionErrorReason.ERROR_CONNECTION

		server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
		if isinstance(server_rpc_api_version, int):
			# compatibility with pre-0.2.0 version
			server_rpc_api_version = (server_rpc_api_version, 0)
		self.logger.info(
			"successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})".format(
				server_version_info['version'],
				server_rpc_api_version[0],
				server_rpc_api_version[1]
			)
		)

		error_text = None
		if server_rpc_api_version[0] < version.rpc_api_version.major or (server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor):
			error_text = 'The server is running an old and incompatible version.'
			error_text += '\nPlease update the remote server installation.'
		elif server_rpc_api_version[0] > version.rpc_api_version.major:
			error_text = 'The client is running an old and incompatible version.'
			error_text += '\nPlease update the local client installation.'
		if error_text:
			gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', active_window, error_text)
			self.emit('server-disconnected')
			return False, ConnectionErrorReason.ERROR_INCOMPATIBLE_VERSIONS

		login_result, login_reason = rpc.login(username, password, otp)
		if not login_result:
			self.logger.warning('failed to authenticate to the remote king phisher service, reason: ' + login_reason)
			self.emit('server-disconnected')
			return False, login_reason
		rpc.username = username
		self.logger.debug('successfully authenticated to the remote king phisher service')

		self.rpc = rpc
		self.emit('server-connected')
		return True, ConnectionErrorReason.SUCCESS

	def do_server_disconnected(self):
		"""Clean up the SSH TCP connections and disconnect from the server."""
		if self.rpc is not None:
			try:
				self.rpc('logout')
			except AdvancedHTTPServerRPCError as error:
				self.logger.warning('failed to logout, rpc error: ' + error.message)
			self.rpc = None
		if self._ssh_forwarder:
			self._ssh_forwarder.stop()
			self._ssh_forwarder = None
		return

	def show_campaign_graph(self, graph_name):
		"""
		Create a new :py:class:`.CampaignGraph` instance and make it into
		a window. *graph_name* must be the name of a valid, exported
		graph provider.

		:param str graph_name: The name of the graph to make a window of.
		"""
		cls = graphs.get_graph(graph_name)
		graph_inst = cls(self, style_context=self.style_context)
		graph_inst.load_graph()
		window = graph_inst.make_window()
		window.show()

	def show_campaign_selection(self):
		"""
		Display the campaign selection dialog in a new
		:py:class:`.CampaignSelectionDialog` instance.

		:return: Whether or not a campaign was selected.
		:rtype: bool
		"""
		dialog = dialogs.CampaignSelectionDialog(self)
		return dialog.interact() == Gtk.ResponseType.APPLY

	def show_preferences(self):
		"""
		Display a
		:py:class:`.dialogs.configuration.ConfigurationDialog`
		instance and saves the configuration to disk if cancel is not selected.
		"""
		dialog = dialogs.ConfigurationDialog(self)
		if dialog.interact() != Gtk.ResponseType.CANCEL:
			self.emit('config-save')

	def signal_css_provider_parsing_error(self, css_provider, css_section, gerror):
		file_path = css_section.get_file()
		if file_path:
			file_path = file_path.get_path()
		else:
			file_path = '[ unknown file ]'
		self.logger.error("css parser error ({0}) in {1}:{2}".format(gerror.message, file_path, css_section.get_start_line()))
		return

	def signal_window_added(self, _, window):
		for action in self.actions.values():
			window.add_action(action)

	def do_sftp_client_start(self):
		"""
		Start the client's preferred sftp client application in a new process.
		"""
		if not self.config['sftp_client']:
			gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), 'An SFTP client is not configured.\nOne can be configured in the Client Preferences.')
			return False
		command = str(self.config['sftp_client'])
		sftp_bin = shlex.split(command)[0]
		if not which(sftp_bin):
			self.logger.error('could not locate the sftp binary: ' + sftp_bin)
			gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Could not find the SFTP binary '{0}'".format(sftp_bin))
			return False
		try:
			command = command.format(
				server=self.config['server'],
				username=self.config['server_username'],
				web_root=self.config['server_config']['server.web_root']
			)
		except KeyError as error:
			self.logger.error("key error while parsing the sftp command for token: {0}".format(error.args[0]))
			gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Invalid token '{0}' in the SFTP command.".format(error.args[0]))
			return False
		self.logger.debug("starting sftp client command: {0}".format(command))
		utilities.start_process(command, wait=False)
		return

	def stop_remote_service(self):
		"""
		Stop the remote King Phisher server. This will request that the
		server stop processing new requests and exit. This will display
		a confirmation dialog before performing the operation. If the
		remote service is stopped, the client will quit.
		"""
		active_window = self.get_active_window()
		if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', active_window, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'):
			return
		self.rpc('shutdown')
		self.logger.info('the remote king phisher service has been stopped')
		gui_utilities.show_dialog_error('Now Exiting', active_window, 'The remote service has been stopped.')
		self.quit()
		return

	@property
	def style_context(self):
		window = self.get_active_window() or self.main_window
		if window is None:
			return None
		return window.get_style_context()
Exemple #12
0
class MailSenderThread(threading.Thread):
	"""
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
	def __init__(self, application, target_file, rpc, tab=None):
		"""
		:param application: The GTK application that the thread is associated with.
		:type application: :py:class:`.KingPhisherClientApplication`
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
		super(MailSenderThread, self).__init__()
		self.daemon = True
		self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__)
		self.application = application
		self.config = self.application.config
		self.target_file = target_file
		"""The name of the target file in CSV format."""
		self.tab = tab
		"""The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
		self.rpc = rpc
		self._ssh_forwarder = None
		self.smtp_connection = None
		"""The :py:class:`smtplib.SMTP` connection instance."""
		self.smtp_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25)
		self.running = threading.Event()
		"""A :py:class:`threading.Event` object indicating if emails are being sent."""
		self.paused = threading.Event()
		"""A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
		self.should_stop = threading.Event()
		self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		self.mail_options = []

	def tab_notify_sent(self, emails_done, emails_total):
		"""
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total))

	def tab_notify_status(self, message):
		"""
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
		self.logger.info(message.lower())
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_status, message + '\n')

	def tab_notify_stopped(self):
		"""
		Notify the tab that the message sending operation has stopped.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_stopped)

	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port forwarding
		with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25)
		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				remote_server,
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
		except paramiko.SSHException as error:
			self.logger.warning("failed with ssh exception '{0}'".format(error.message))
		except Exception:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
		else:
			self.smtp_server = self._ssh_forwarder.local_server
			return ConnectionErrorReason.SUCCESS
		return ConnectionErrorReason.ERROR_UNKNOWN

	def server_smtp_connect(self):
		"""
		Connect and optionally authenticate to the configured SMTP server.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		if self.config.get('smtp_ssl_enable', False):
			SmtpClass = smtplib.SMTP_SSL
		else:
			SmtpClass = smtplib.SMTP
		self.logger.debug('opening a new connection to the SMTP server')
		try:
			self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15)
			self.smtp_connection.ehlo()
		except smtplib.SMTPException:
			self.logger.warning('received an SMTPException while connecting to the SMTP server', exc_info=True)
			return ConnectionErrorReason.ERROR_UNKNOWN
		except socket.error:
			self.logger.warning('received a socket.error while connecting to the SMTP server')
			return ConnectionErrorReason.ERROR_CONNECTION

		if not self.config.get('smtp_ssl_enable', False) and 'starttls' in self.smtp_connection.esmtp_features:
			self.logger.debug('target SMTP server supports the STARTTLS extension')
			try:
				self.smtp_connection.starttls()
				self.smtp_connection.ehlo()
			except smtplib.SMTPException:
				self.logger.warning('received an SMTPException while negotiating STARTTLS with the SMTP server', exc_info=True)
				return ConnectionErrorReason.ERROR_UNKNOWN

		username = self.config.get('smtp_username', '')
		if username:
			password = self.config.get('smtp_password', '')
			try:
				self.smtp_connection.login(username, password)
			except smtplib.SMTPException as error:
				self.logger.warning('received an {0} while authenticating to the SMTP server'.format(error.__class__.__name__))
				self.smtp_connection.quit()
				return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED

		if self.smtp_connection.has_extn('SMTPUTF8'):
			self.logger.debug('target SMTP server supports the SMTPUTF8 extension')
			self.mail_options.append('SMTPUTF8')
		return ConnectionErrorReason.SUCCESS

	def server_smtp_disconnect(self):
		"""Clean up and close the connection to the remote SMTP server."""
		if self.smtp_connection:
			self.logger.debug('closing the connection to the SMTP server')
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
			self.tab_notify_status('Disconnected from the SMTP server')

	def server_smtp_reconnect(self):
		"""
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
		while self.server_smtp_connect() != ConnectionErrorReason.SUCCESS:
			self.tab_notify_status('Failed to reconnect to the SMTP server')
			if not self.process_pause(True):
				return False
		return True

	def count_targets(self):
		"""
		Count the number of targets that will be sent messages.

		:return: The number of targets that will be sent messages.
		:rtype: int
		"""
		return sum(1 for _ in self.iterate_targets(counting=True))

	def iterate_targets(self, counting=False):
		"""
		Iterate over each of the targets as defined within the configuration.
		If *counting* is ``False``, messages will not be displayed to the end
		user through the notification tab.

		:param bool counting: Whether or not to iterate strictly for counting purposes.
		:return: Each message target.
		:rtype: :py:class:`~.MessageTarget`
		"""
		mailer_tab = self.application.main_tabs['mailer']
		target_type = self.config['mailer.target_type']
		if target_type == 'single':
			target_name = self.config['mailer.target_name'].split(' ')
			while len(target_name) < 2:
				target_name.append('')
			uid_charset = self.config['mailer.message_uid.charset']
			target = MessageTarget(
				first_name=target_name[0].strip(),
				last_name=target_name[1].strip(),
				email_address=self.config['mailer.target_email_address'].strip(),
				uid=utilities.make_message_uid(
					upper=uid_charset['upper'],
					lower=uid_charset['lower'],
					digits=uid_charset['digits']
				)
			)
			if not counting:
				mailer_tab.emit('target-create', target)
			yield target
		elif target_type == 'file':
			for target in _iterate_targets_file(self.target_file, config=self.config):
				missing_fields = target.missing_fields
				if missing_fields:
					if counting:
						msg = "Target CSV line {0} skipped due to missing field{1}".format(target.line, ('' if len(missing_fields) == 1 else 's'))
						msg += ':' + ', '.join(field.replace('_', ' ') for field in missing_fields)
						self.tab_notify_status(msg)
					continue
				if not utilities.is_valid_email_address(target.email_address):
					self.logger.warning("skipping line {0} in target csv file due to invalid email address: {1}".format(target.line, target.email_address))
					continue
				if not counting:
					mailer_tab.emit('target-create', target)
				yield target
		else:
			self.logger.error("the configured target type '{0}' is unsupported".format(target_type))

	def run(self):
		"""The entry point of the thread."""
		self.logger.debug("mailer routine running in tid: 0x{0:x}".format(threading.current_thread().ident))
		self.running.set()
		self.should_stop.clear()
		self.paused.clear()

		try:
			self._prepare_env()
			emails_done = self._send_messages()
		except UnicodeDecodeError as error:
			self.logger.error("a unicode error occurred, {0} at position: {1}-{2}".format(error.reason, error.start, error.end))
			self.tab_notify_status("A unicode error occurred, {0} at position: {1}-{2}".format(error.reason, error.start, error.end))
		except Exception:
			self.logger.error('an error occurred while sending messages', exc_info=True)
			self.tab_notify_status('An error occurred while sending messages.')
		else:
			self.tab_notify_status("Finished sending, successfully sent {0:,} messages".format(emails_done))

		self.server_smtp_disconnect()
		if self._ssh_forwarder:
			self._ssh_forwarder.stop()
			self._ssh_forwarder = None
			self.tab_notify_status('Disconnected from the SSH server')
		self.tab_notify_stopped()
		return

	def process_pause(self, set_pause=False):
		"""
		Pause sending emails if a pause request has been set.

		:param bool set_pause: Whether to request a pause before processing it.
		:return: Whether or not the sending operation was cancelled during the pause.
		:rtype: bool
		"""
		if set_pause:
			if isinstance(self.tab, gui_utilities.GladeGObject):
				gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True))
			else:
				self.pause()
		if self.paused.is_set():
			self.tab_notify_status('Paused sending emails, waiting to resume')
			self.running.wait()
			self.paused.clear()
			if self.should_stop.is_set():
				self.tab_notify_status('Sending emails cancelled')
				return False
			self.tab_notify_status('Resuming sending emails')
			self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		return True

	def create_message(self, target=None):
		if target is None:
			target = MessageTargetPlaceholder(uid=self.config['server_config'].get('server.secret_id'))
		attachments = self.get_mime_attachments()
		message = getattr(self, 'create_message_' + self.config['mailer.message_type'])(target, attachments)
		mailer_tab = self.application.main_tabs['mailer']
		mailer_tab.emit('message-create', target, message)
		return message

	def create_message_calendar_invite(self, target, attachments):
		"""
		Create a MIME calendar invite to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`Attachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
		top_msg = TopMIMEMultipart('mixed', self.config, target)
		top_msg['To'] = target.email_address

		related_msg = mime.multipart.MIMEMultipart('related')
		top_msg.attach(related_msg)

		alt_msg = mime.multipart.MIMEMultipart('alternative')
		related_msg.attach(alt_msg)

		part = mime.base.MIMEBase('text', 'plain', charset='utf-8')
		part.set_payload(MIME_TEXT_PLAIN)
		encoders.encode_base64(part)
		alt_msg.attach(part)

		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		formatted_msg = render_message_template(msg_template, self.config, target=target)
		part = MIMEText(formatted_msg, 'html')
		alt_msg.attach(part)

		start_time = get_invite_start_from_config(self.config)
		if self.config['mailer.calendar_invite_all_day']:
			duration = ics.DurationAllDay()
		else:
			duration = int(self.config['mailer.calendar_invite_duration']) * 60
		ical = ics.Calendar(
			self.config['mailer.source_email'],
			start_time,
			self.config.get('mailer.calendar_invite_summary'),
			duration=duration,
			location=self.config.get('mailer.calendar_invite_location')
		)
		ical.add_attendee(target.email_address, rsvp=self.config.get('mailer.calendar_request_rsvp', False))

		part = mime.base.MIMEBase('text', 'calendar', charset='utf-8', method='REQUEST')
		part.set_payload(ical.to_ical(encoding='utf-8'))
		encoders.encode_base64(part)
		alt_msg.attach(part)

		for attach in attachments.images:
			related_msg.attach(attach)

		for attach in attachments.files:
			top_msg.attach(attach)
		return top_msg

	def create_message_email(self, target, attachments):
		"""
		Create a MIME email to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`MessageAttachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
		msg = TopMIMEMultipart('related', self.config, target)
		target_field = self.config.get('mailer.target_field', 'to').lower()
		for header in ('To', 'CC', 'BCC'):
			if header.lower() == target_field:
				msg[header] = '<' + target.email_address + '>'
				continue
			value = self.config.get('mailer.recipient_email_' + header.lower())
			if value:
				msg[header] = '<' + value + '>'

		importance = self.config.get('mailer.importance', 'Normal')
		if importance != 'Normal':
			msg['Importance'] = importance
		sensitivity = self.config.get('mailer.sensitivity', 'Normal')
		if sensitivity != 'Normal':
			msg['Sensitivity'] = sensitivity

		msg_alt = mime.multipart.MIMEMultipart('alternative')
		msg.attach(msg_alt)
		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		formatted_msg = render_message_template(msg_template, self.config, target=target)
		# RFC-1341 page 35 states friendliest part must be attached first
		msg_body = MIMEText(MIME_TEXT_PLAIN, 'plain')
		msg_alt.attach(msg_body)
		msg_body = MIMEText(formatted_msg, 'html')
		msg_alt.attach(msg_body)
		msg_alt.set_default_type('html')

		# process attachments
		for attach in attachments.files:
			msg.attach(attach)
		for attach in attachments.images:
			msg.attach(attach)
		return msg

	def get_mime_attachments(self):
		"""
		Return a :py:class:`.MessageAttachments` object containing both the images and
		raw files to be included in sent messages.

		:return: A namedtuple of both files and images in their MIME containers.
		:rtype: :py:class:`.MessageAttachments`
		"""
		files = []
		# allow the attachment_file.post_processing to be attached instead of
		# attachment_file so attachment_file can be used as an input for
		# arbitrary operations to modify without over writing the original
		attachment_file = self.config.get('mailer.attachment_file.post_processing')
		delete_attachment_file = False
		if attachment_file is not None:
			if not isinstance(attachment_file, str):
				raise TypeError('config option mailer.attachment_file.post_processing is not a readable file')
			if not os.path.isfile(attachment_file) and os.access(attachment_file, os.R_OK):
				raise ValueError('config option mailer.attachment_file.post_processing is not a readable file')
			self.config['mailer.attachment_file.post_processing'] = None
			delete_attachment_file = True
		else:
			attachment_file = self.config.get('mailer.attachment_file')
		if attachment_file:
			attachfile = mime.base.MIMEBase(*mimetypes.guess_type(attachment_file))
			attachfile.set_payload(open(attachment_file, 'rb').read())
			encoders.encode_base64(attachfile)
			attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment_file)))
			files.append(attachfile)
			if delete_attachment_file and os.access(attachment_file, os.W_OK):
				os.remove(attachment_file)

		images = []
		for attachment_file, attachment_name in template_environment.attachment_images.items():
			attachfile = mime.image.MIMEImage(open(attachment_file, 'rb').read())
			attachfile.add_header('Content-ID', "<{0}>".format(attachment_name))
			attachfile.add_header('Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name))
			images.append(attachfile)
		return MessageAttachments(tuple(files), tuple(images))

	def _prepare_env(self):
		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		render_message_template(msg_template, self.config, analyze=True)
		template_environment.set_mode(template_environment.MODE_SEND)

	def _send_messages(self):
		emails_done = 0
		mailer_tab = self.application.main_tabs['mailer']
		max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5)

		emails_total = "{0:,}".format(self.count_targets())
		sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(len(emails_total), emails_total)
		emails_total = int(emails_total.replace(',', ''))

		for target in self.iterate_targets():
			iteration_time = time.time()
			if self.should_stop.is_set():
				self.tab_notify_status('Sending emails cancelled')
				break
			if not self.process_pause():
				break
			if emails_done > 0 and max_messages_per_connection > 0 and (emails_done % max_messages_per_connection == 0):
				self.server_smtp_reconnect()

			emails_done += 1
			if not all(mailer_tab.emit('target-send', target)):
				self.logger.info("target-send signal subscriber vetoed target: {0!r}".format(target))
				continue
			self.tab_notify_status(sending_line.format(emails_done, target.uid, target.email_address))

			message = self.create_message(target=target)
			if not all(mailer_tab.emit('message-send', target, message)):
				self.logger.info("message-send signal subscriber vetoed message to target: {0!r}".format(target))
				continue
			self.rpc(
				'campaign/message/new/deferred',
				self.config['campaign_id'],
				target.uid,
				target.email_address,
				target.first_name,
				target.last_name,
				target.department
			)
			if not self._try_send_message(target.email_address, message):
				self.rpc('db/table/delete', 'messages', target.uid)
				break
			self.rpc('db/table/set', 'messages', target.uid, ('sent',), (datetime.datetime.utcnow(),))

			self.tab_notify_sent(emails_done, emails_total)
			self.application.emit('message-sent', target.uid, target.email_address)

			if self.max_messages_per_minute:
				iteration_time = (time.time() - iteration_time)
				self._sleep((60.0 / float(self.max_messages_per_minute)) - iteration_time)
		return emails_done

	def _sleep(self, duration):
		while duration > 0:
			sleep_chunk = min(duration, 0.5)
			time.sleep(sleep_chunk)
			if self.should_stop.is_set():
				break
			duration -= sleep_chunk
		return self.should_stop.is_set()

	def _try_send_message(self, *args, **kwargs):
		message_sent = False
		while not message_sent and not self.should_stop.is_set():
			for i in range(0, 3):
				try:
					self.send_message(*args, **kwargs)
					message_sent = True
					break
				except smtplib.SMTPServerDisconnected:
					self.logger.warning('failed to send message, the server has been disconnected')
					self.tab_notify_status('Failed to send message, the server has been disconnected')
					self.tab_notify_status('Sleeping for 5 seconds before attempting to reconnect')
					if self._sleep(5):
						break
					self.smtp_connection = None
					self.server_smtp_reconnect()
				except smtplib.SMTPException as error:
					self.tab_notify_status("Failed to send message (exception: {0})".format(error.__class__.__name__))
					self.logger.warning("failed to send message (exception: smtplib.{0})".format(error.__class__.__name__))
					self._sleep((i + 1) ** 2)
			if not message_sent:
				self.server_smtp_disconnect()
				if not self.process_pause(True):
					return False
				self.server_smtp_reconnect()
		return True

	def send_message(self, target_email, msg):
		"""
		Send an email using the connected SMTP server.

		:param str target_email: The email address to send the message to.
		:param msg: The formatted message to be sent.
		:type msg: :py:class:`.mime.multipart.MIMEMultipart`
		"""
		source_email = self.config['mailer.source_email_smtp']
		self.smtp_connection.sendmail(source_email, target_email, msg.as_string(), self.mail_options)

	def pause(self):
		"""
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is paused.
		"""
		self.running.clear()
		self.paused.set()

	def unpause(self):
		"""
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is no longer paused.
		"""
		self.running.set()

	def stop(self):
		"""
		Requests that the email sending operation stop. It can not be
		resumed from the same position. This function blocks until the
		stop request has been processed and the thread exits.
		"""
		self.should_stop.set()
		self.unpause()
		if self.is_alive():
			self.join()

	def missing_files(self):
		"""
		Return a list of all missing or unreadable files which are referenced by
		the message template.

		:return: The list of unusable files.
		:rtype: list
		"""
		missing = []
		attachment = self.config.get('mailer.attachment_file')
		if attachment and not os.access(attachment, os.R_OK):
			missing.append(attachment)
		msg_template = self.config['mailer.html_file']
		if not os.access(msg_template, os.R_OK):
			missing.append(msg_template)
			return missing
		self._prepare_env()
		for attachment in template_environment.attachment_images.keys():
			if not os.access(attachment, os.R_OK):
				missing.append(attachment)
		return missing
Exemple #13
0
class MailSenderThread(threading.Thread):
	"""
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
	def __init__(self, config, target_file, rpc, tab=None):
		"""
		:param dict config: The King Phisher client configuration.
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
		super(MailSenderThread, self).__init__()
		self.daemon = True
		self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__)
		self.config = config
		self.target_file = target_file
		"""The name of the target file in CSV format."""
		self.tab = tab
		"""The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
		self.rpc = rpc
		self._ssh_forwarder = None
		self.smtp_connection = None
		"""The :py:class:`smtplib.SMTP` connection instance."""
		self.smtp_server = parse_server(self.config['smtp_server'], 25)
		self.running = threading.Event()
		"""A :py:class:`threading.Event` object indicating if emails are being sent."""
		self.paused = threading.Event()
		"""A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
		self.should_exit = threading.Event()
		self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		self._mime_attachments = None

	def tab_notify_sent(self, emails_done, emails_total):
		"""
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total))

	def tab_notify_status(self, message):
		"""
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
		self.logger.info(message.lower())
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_status, message + '\n')

	def tab_notify_stopped(self):
		"""
		Notify the tab that the message sending operation has stopped.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_stopped)

	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port
		forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP
		traffic.

		:return: The connection status.
		:rtype: bool
		"""
		server = parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = parse_server(self.config['smtp_server'], 25)
		local_port = random.randint(2000, 6000)
		try:
			self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key'))
			self._ssh_forwarder.start()
			time.sleep(0.5)
		except Exception:
			self.logger.warning('failed to connect to remote ssh server', exc_info=True)
			return False
		self.smtp_server = ('localhost', local_port)
		return True

	def server_smtp_connect(self):
		"""
		Connect to the configured SMTP server.

		:return: The connection status.
		:rtype: bool
		"""
		if self.config.get('smtp_ssl_enable', False):
			SmtpClass = smtplib.SMTP_SSL
		else:
			SmtpClass = smtplib.SMTP
		try:
			self.smtp_connection = SmtpClass(*self.smtp_server, timeout=10)
		except socket.error:
			self.logger.warning('received a socket.error while connecting to the SMTP server')
		except smtplib.SMTPException:
			self.logger.warning('received an SMTPException while connecting to the SMTP server')
		else:
			return True
		return False

	def server_smtp_disconnect(self):
		"""Clean up and close the connection to the remote SMTP server."""
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
			self.tab_notify_status('Disconnected from the SMTP server')

	def server_smtp_reconnect(self):
		"""
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
		while not self.server_smtp_connect():
			self.tab_notify_status('Failed to reconnect to the SMTP server')
			if not self.process_pause(True):
				return False
		return True

	def count_emails(self):
		"""
		Count the emails contained in the target CSV file.

		:return: The number of targets in the file.
		:rtype: int
		"""
		targets = 0
		target_file_h = open(self.target_file, 'rU')
		csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address'])
		for target in csv_reader:
			if not utilities.is_valid_email_address(target['email_address']):
				continue
			targets += 1
		target_file_h.close()
		return targets

	def run(self):
		emails_done = 0
		emails_total = self.count_emails()
		max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5)
		self.running.set()
		self.should_exit.clear()
		self.paused.clear()
		self._prepare_env()

		emails_total = "{0:,}".format(emails_total)
		sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(len(emails_total), emails_total)
		emails_total = int(emails_total.replace(',', ''))
		self._mime_attachments = self._get_mime_attachments()
		self.logger.debug("loaded {0:,} MIME attachments".format(len(self._mime_attachments)))

		target_file_h = open(self.target_file, 'rU')
		csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address', 'department'])
		for target in csv_reader:
			if not utilities.is_valid_email_address(target['email_address']):
				if target['email_address']:
					self.logger.warning('skipping invalid email address: ' + target['email_address'])
				else:
					self.logger.warning('skipping blank email address')
				continue
			iteration_time = time.time()
			if self.should_exit.is_set():
				self.tab_notify_status('Sending emails cancelled')
				break
			if not self.process_pause():
				break
			if emails_done > 0 and (emails_done % max_messages_per_connection):
				self.server_smtp_reconnect()

			uid = make_uid()
			emails_done += 1
			self.tab_notify_status(sending_line.format(emails_done, uid, target['email_address']))
			msg = self.create_email(target['first_name'], target['last_name'], target['email_address'], uid)
			if not self._try_send_email(target['email_address'], msg):
				break

			self.tab_notify_sent(emails_done, emails_total)
			campaign_id = self.config['campaign_id']
			department = target['department']
			if department is not None:
				department = department.strip()
				if department == '':
					department = None
			self.rpc('campaign/message/new', campaign_id, uid, target['email_address'], target['first_name'], target['last_name'], department)

			if self.max_messages_per_minute:
				iteration_time = (time.time() - iteration_time)
				sleep_time = (60.0 / float(self.max_messages_per_minute)) - iteration_time
				while sleep_time > 0:
					sleep_chunk = min(sleep_time, 0.5)
					time.sleep(sleep_chunk)
					if self.should_exit.is_set():
						break
					sleep_time -= sleep_chunk

		target_file_h.close()
		self._mime_attachments = None

		self.tab_notify_status("Finished sending emails, successfully sent {0:,} emails".format(emails_done))
		self.server_smtp_disconnect()
		if self._ssh_forwarder:
			self._ssh_forwarder.stop()
			self._ssh_forwarder = None
			self.tab_notify_status('Disconnected from the SSH server')
		self.tab_notify_stopped()
		return

	def process_pause(self, set_pause=False):
		"""
		Pause sending emails if a pause request has been set.

		:param bool set_pause: Whether to request a pause before processing it.
		:return: Whether or not the sending operation was cancelled during the pause.
		:rtype: bool
		"""
		if set_pause:
			if isinstance(self.tab, gui_utilities.GladeGObject):
				gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True))
			else:
				self.pause()
		if self.paused.is_set():
			self.tab_notify_status('Paused sending emails, waiting to resume')
			self.running.wait()
			self.paused.clear()
			if self.should_exit.is_set():
				self.tab_notify_status('Sending emails cancelled')
				return False
			self.tab_notify_status('Resuming sending emails')
			self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		return True

	def create_email(self, first_name, last_name, target_email, uid):
		"""
		Create a MIME email to be sent from a set of parameters.

		:param str first_name: The first name of the message's recipient.
		:param str last_name: The last name of the message's recipient.
		:param str target_email: The message's destination email address.
		:param str uid: The message's unique identifier.
		:return: The new MIME message.
		:rtype: :py:class:`email.MIMEMultipart.MIMEMultipart`
		"""
		msg = MIMEMultipart()
		msg.replace_header('Content-Type', 'multipart/related')
		msg['Subject'] = self.config['mailer.subject']
		if self.config.get('mailer.reply_to_email'):
			msg.add_header('reply-to', self.config['mailer.reply_to_email'])
		if self.config.get('mailer.source_email_alias'):
			msg['From'] = "\"{0}\" <{1}>".format(self.config['mailer.source_email_alias'], self.config['mailer.source_email'])
		else:
			msg['From'] = self.config['mailer.source_email']
		msg['To'] = target_email
		importance = self.config.get('mailer.importance', 'Normal')
		if importance != 'Normal':
			msg['Importance'] = importance
		sensitivity = self.config.get('mailer.sensitivity', 'Normal')
		if sensitivity != 'Normal':
			msg['Sensitivity'] = sensitivity
		msg.preamble = 'This is a multi-part message in MIME format.'

		msg_alt = MIMEMultipart('alternative')
		msg.attach(msg_alt)
		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		formatted_msg = format_message(msg_template, self.config, first_name=first_name, last_name=last_name, uid=uid, target_email=target_email)
		msg_body = MIMEText(formatted_msg, 'html', 'utf-8')
		msg_alt.attach(msg_body)

		# process attachments
		if isinstance(self._mime_attachments, (list, tuple)):
			attachfiles = self._mime_attachments
		else:
			attachfiles = self._get_mime_attachments()
		for attachfile in attachfiles:
			msg.attach(attachfile)
		return msg

	def _get_mime_attachments(self):
		attachments = []
		if self.config.get('mailer.attachment_file'):
			attachment = self.config['mailer.attachment_file']
			attachfile = MIMEBase(*mimetypes.guess_type(attachment))
			attachfile.set_payload(open(attachment, 'rb').read())
			encoders.encode_base64(attachfile)
			attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment)))
			attachments.append(attachfile)
		for attachment_file, attachment_name in template_environment.attachment_images.items():
			attachfile = MIMEImage(open(attachment_file, 'rb').read())
			attachfile.add_header('Content-ID', "<{0}>".format(attachment_name))
			attachfile.add_header('Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name))
			attachments.append(attachfile)
		return attachments

	def _prepare_env(self):
		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		template_environment.set_mode(template_environment.MODE_ANALYZE)
		format_message(msg_template, self.config, uid=make_uid())
		template_environment.set_mode(template_environment.MODE_SEND)

	def _try_send_email(self, *args, **kwargs):
		message_sent = False
		while not message_sent:
			for _ in range(0, 3):
				try:
					self.send_email(*args, **kwargs)
					message_sent = True
					break
				except smtplib.SMTPException:
					self.tab_notify_status('Failed to send message')
					time.sleep(1)
			if not message_sent:
				self.server_smtp_disconnect()
				if not self.process_pause(True):
					return False
				self.server_smtp_reconnect()
		return True

	def send_email(self, target_email, msg):
		"""
		Send an email using the connected SMTP server.

		:param str target_email: The email address to send the message to.
		:param msg: The formatted message to be sent.
		:type msg: :py:class:`email.MIMEMultipart.MIMEMultipart`
		"""
		source_email = self.config['mailer.source_email_smtp']
		self.smtp_connection.sendmail(source_email, target_email, msg.as_string())

	def pause(self):
		"""
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is paused.
		"""
		self.running.clear()
		self.paused.set()

	def unpause(self):
		"""
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is no longer paused.
		"""
		self.running.set()

	def stop(self):
		"""
		Requests that the email sending operation stop. It can not be
		resumed from the same position. This function blocks until the
		stop request has been processed and the thread exits.
		"""
		self.should_exit.set()
		self.unpause()
		if self.is_alive():
			self.join()

	def missing_files(self):
		"""
		Return a list of all missing or unreadable files which are referenced by
		the message template.

		:return: The list of unusable files.
		:rtype: list
		"""
		missing = []
		attachment = self.config.get('mailer.attachment_file')
		if attachment and not os.access(attachment, os.R_OK):
			missing.append(attachment)
		msg_template = self.config['mailer.html_file']
		if not os.access(msg_template, os.R_OK):
			missing.append(msg_template)
			return missing
		self._prepare_env()
		for attachment in template_environment.attachment_images.keys():
			if not os.access(attachment, os.R_OK):
				missing.append(attachment)
		return missing
class KingPhisherClientApplication(_Gtk_Application):
    """
	This is the top level King Phisher client object. It contains the
	custom GObject signals, keeps all the GUI references, and manages
	the RPC client object. This is also the parent window for most
	GTK objects.

	:GObject Signals: :ref:`gobject-signals-application-label`
	"""
    # pylint: disable=too-many-public-methods
    __gsignals__ = {
        'campaign-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, )),
        'campaign-created': (GObject.SIGNAL_RUN_FIRST, None, (str, )),
        'campaign-delete':
        (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (str, )),
        'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str, )),
        'config-load':
        (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (bool, )),
        'config-save':
        (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
        'credential-delete':
        (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object, )),
        'exit': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()),
        'exit-confirm': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None,
                         ()),
        'message-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST,
                           None, (object, )),
        'message-sent': (GObject.SIGNAL_RUN_FIRST, None, (str, str)),
        'reload-css-style': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST,
                             None, ()),
        'rpc-cache-clear': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST,
                            None, ()),
        'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ()),
        'server-disconnected': (GObject.SIGNAL_RUN_FIRST, None, ()),
        'sftp-client-start': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST,
                              None, ()),
        'visit-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None,
                         (object, )),
    }

    def __init__(self, config_file=None, use_plugins=True, use_style=True):
        super(KingPhisherClientApplication, self).__init__()
        if use_style:
            gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version())
            if gtk_version > (3, 18):
                self._theme_file = 'theme.v2.css'
            else:
                self._theme_file = 'theme.v1.css'
        else:
            self._theme_file = DISABLED
        self.logger = logging.getLogger('KingPhisher.Client.Application')
        # log version information for debugging purposes
        self.logger.debug("gi.repository GLib version: {0}".format('.'.join(
            map(str, GLib.glib_version))))
        self.logger.debug("gi.repository GObject version: {0}".format('.'.join(
            map(str, GObject.pygobject_version))))
        self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format(
            Gtk.get_major_version(), Gtk.get_minor_version(),
            Gtk.get_micro_version()))
        if rpc_terminal.has_vte:
            self.logger.debug("gi.repository VTE version: {0}".format(
                rpc_terminal.Vte._version))
        if graphs.has_matplotlib:
            self.logger.debug("matplotlib version: {0}".format(
                graphs.matplotlib.__version__))
        self.set_property('application-id', 'org.king-phisher.client')
        self.set_property('register-session', True)
        self.config_file = config_file or os.path.join(USER_DATA_PATH,
                                                       'config.json')
        """The file containing the King Phisher client configuration."""
        if not os.path.isfile(self.config_file):
            self._create_config()
        self.config = None
        """The primary King Phisher client configuration."""
        self.main_window = None
        """The primary top-level :py:class:`~.MainAppWindow` instance."""
        self.rpc = None
        """The :py:class:`~.KingPhisherRPCClient` instance for the application."""
        self._ssh_forwarder = None
        """The SSH forwarder responsible for tunneling RPC communications."""
        self.style_provider = None
        try:
            self.emit('config-load', True)
        except IOError:
            self.logger.critical('failed to load the client configuration')
            raise

        self.connect('window-added', self.signal_window_added)
        self.actions = {}
        self._create_actions()

        if not use_plugins:
            self.logger.info('disabling all plugins')
            self.config['plugins.enabled'] = []
        self.plugin_manager = plugins.ClientPluginManager([
            os.path.join(USER_DATA_PATH, 'plugins'),
            find.find_data_directory('plugins')
        ], self)
        if use_plugins:
            self.plugin_manager.load_all()

    def _create_actions(self):
        action = Gio.SimpleAction.new('emit-application-signal',
                                      GLib.VariantType.new('s'))
        action.connect('activate', self.action_emit_application_signal)
        accelerators = (('<Control><Shift>F1', 'rpc-cache-clear'),
                        ('<Control><Shift>F2', 'config-save'),
                        ('<Control><Shift>F12', 'reload-css-style'))
        for key, signal_name in accelerators:
            if Gtk.check_version(3, 14, 0):
                self.add_accelerator(key, 'win.emit-application-signal',
                                     GLib.Variant.new_string(signal_name))
            else:
                self.set_accels_for_action(
                    "win.emit-application-signal('{0}')".format(signal_name),
                    (key, ))
        self.actions['emit-application-signal'] = action
        self.add_action(action)

    def _create_ssh_forwarder(self, server, username, password):
        """
		Create and set the
		:py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
        active_window = self.get_active_window()
        title_ssh_error = 'Failed To Connect To The SSH Service'
        server_remote_port = self.config['server_remote_port']

        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password, ('127.0.0.1', server_remote_port),
                private_key=self.config.get('ssh_preferred_key'),
                missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(
                    self))
            self._ssh_forwarder.start()
        except errors.KingPhisherAbortError as error:
            self.logger.info("ssh connection aborted ({0})".format(
                error.message))
        except paramiko.PasswordRequiredException:
            gui_utilities.show_dialog_error(
                title_ssh_error, active_window,
                'The specified SSH key requires a password.')
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            gui_utilities.show_dialog_error(
                title_ssh_error, active_window,
                'The server responded that the credentials are invalid.')
        except paramiko.SSHException as error:
            self.logger.warning("failed with ssh exception '{0}'".format(
                error.args[0]))
        except socket.error as error:
            gui_utilities.show_dialog_exc_socket_error(error,
                                                       active_window,
                                                       title=title_ssh_error)
        except Exception as error:
            self.logger.warning('failed to connect to the remote ssh server',
                                exc_info=True)
            gui_utilities.show_dialog_error(
                title_ssh_error, active_window,
                "An {0}.{1} error occurred.".format(error.__class__.__module__,
                                                    error.__class__.__name__))
        else:
            return self._ssh_forwarder.local_server
        self.emit('server-disconnected')
        return

    def _create_config(self):
        config_dir = os.path.dirname(self.config_file)
        if not os.path.isdir(config_dir):
            self.logger.debug('creating the user configuration directory')
            os.makedirs(config_dir)
        # move the pre 1.0.0 config file if it exists
        old_path = os.path.expanduser('~/.king_phisher.json')
        if os.path.isfile(old_path) and os.access(old_path, os.R_OK):
            self.logger.debug('moving the old config file to the new location')
            os.rename(old_path, self.config_file)
        else:
            client_template = find.find_data_file('client_config.json')
            shutil.copy(client_template, self.config_file)

    def campaign_configure(self):
        assistant = assistants.CampaignAssistant(
            self, campaign_id=self.config['campaign_id'])
        assistant.assistant.set_transient_for(self.get_active_window())
        assistant.assistant.set_modal(True)

        # do this to keep a reference to prevent garbage collection
        attr_name = '_tmpref_campaign_assistant'
        setattr(self, attr_name, assistant)
        assistant.assistant.connect('destroy',
                                    lambda widget: delattr(self, attr_name))

        assistant.interact()

    def do_campaign_delete(self, campaign_id):
        """
		Delete the campaign on the server. A confirmation dialog will be
		displayed before the operation is performed. If the campaign is deleted
		and a new campaign is not selected with
		:py:meth:`.show_campaign_selection`, the client will quit.
		"""
        self.rpc('db/table/delete', 'campaigns', campaign_id)
        if campaign_id == self.config[
                'campaign_id'] and not self.show_campaign_selection():
            gui_utilities.show_dialog_error('Now Exiting',
                                            self.get_active_window(),
                                            'A campaign must be selected.')
            self.quit()

    def do_credential_delete(self, row_ids):
        if len(row_ids) == 1:
            self.rpc('db/table/delete', 'credentials', row_ids[0])
        else:
            self.rpc('db/table/delete/multi', 'credentials', row_ids)

    def do_message_delete(self, row_ids):
        if len(row_ids) == 1:
            self.rpc('db/table/delete', 'messages', row_ids[0])
        else:
            self.rpc('db/table/delete/multi', 'messages', row_ids)

    def do_visit_delete(self, row_ids):
        if len(row_ids) == 1:
            self.rpc('db/table/delete', 'visits', row_ids[0])
        else:
            self.rpc('db/table/delete/multi', 'visits', row_ids)

    def campaign_rename(self):
        """
		Show a dialog prompting the user to for the a new name to assign to the
		currently selected campaign.
		"""
        campaign = self.rpc.remote_table_row('campaigns',
                                             self.config['campaign_id'])
        prompt = dialogs.TextEntryDialog.build_prompt(
            self, 'Rename Campaign', 'Enter the new campaign name:',
            campaign.name)
        response = prompt.interact()
        if response is None or response == campaign.name:
            return
        self.rpc('db/table/set', 'campaigns', self.config['campaign_id'],
                 'name', response)
        gui_utilities.show_dialog_info(
            'Campaign Name Updated', self.get_active_window(),
            'The campaign name was successfully changed.')

    def exception_hook(self, exc_type, exc_value, exc_traceback):
        if isinstance(exc_value, KeyboardInterrupt):
            self.logger.warning('received a KeyboardInterrupt exception')
            return
        exc_info = (exc_type, exc_value, exc_traceback)
        error_uid = str(uuid.uuid4())
        self.logger.error(
            "error uid: {0} an unhandled exception was thrown".format(
                error_uid),
            exc_info=exc_info)
        dialogs.ExceptionDialog(self, exc_info=exc_info,
                                error_uid=error_uid).interact()

    def quit(self, optional=False):
        """
		Quit the client and perform any necessary clean up operations. If
		*optional* is False then the exit-confirm signal will not be sent and
		there will not be any opportunities for the client to cancel the
		operation.

		:param bool optional: Whether the quit is request is optional or not.
		"""
        self.emit('exit-confirm' if optional else 'exit')

    def action_emit_application_signal(self, _, signal_name):
        signal_name = signal_name.get_string()
        self.logger.debug('action emit-application-signal invoked for ' +
                          signal_name)
        self.emit(signal_name)

    def do_activate(self):
        Gtk.Application.do_activate(self)
        sys.excepthook = self.exception_hook

        # reset theme settings to defaults so we have a standard baseline
        settings = Gtk.Settings.get_default()
        if settings.get_property('gtk-theme-name') != GTK3_DEFAULT_THEME:
            self.logger.debug(
                'resetting the gtk-theme-name property to it\'s default value')
            settings.set_property('gtk-theme-name', GTK3_DEFAULT_THEME)
        if settings.get_property('gtk-icon-theme-name') != GTK3_DEFAULT_THEME:
            self.logger.debug(
                'resetting the gtk-icon-theme-name property to it\'s default value'
            )
            settings.set_property('gtk-icon-theme-name', GTK3_DEFAULT_THEME)
        settings.set_property('gtk-application-prefer-dark-theme', False)

        # load a custom css theme file if one is available
        theme_file = self.theme_file
        if theme_file:
            self.style_provider = self.load_style_css(theme_file)
        elif theme_file is DISABLED:
            self.logger.debug(
                'no css theme file will be loaded (styling has been disabled)')
        else:
            self.logger.debug(
                'no css theme file will be loaded (file not found)')

        # create and show the main window
        self.main_window = main.MainAppWindow(self.config, self)
        self.main_tabs = self.main_window.tabs

        for name in list(self.config['plugins.enabled']):
            try:
                self.plugin_manager.load(name)
                self.plugin_manager.enable(name)
            except Exception:
                self.config['plugins.enabled'].remove(name)
                gui_utilities.show_dialog_error(
                    'Failed To Enable Plugin', self.main_window,
                    "Plugin '{0}' could not be enabled.".format(name))

    def do_campaign_set(self, campaign_id):
        self.logger.info("campaign set to {0} (id: {1})".format(
            self.config['campaign_name'], self.config['campaign_id']))
        self.emit('rpc-cache-clear')

    def do_config_save(self):
        self.logger.info('writing the client configuration to disk')
        config = copy.copy(self.config)
        for key in self.config.keys():
            if 'password' in key or key == 'server_config':
                del config[key]
        with open(os.path.expanduser(self.config_file), 'w') as config_file_h:
            json_ex.dump(config, config_file_h)

    def do_exit(self):
        self.plugin_manager.shutdown()

        self.main_window.hide()
        gui_utilities.gtk_widget_destroy_children(self.main_window)
        gui_utilities.gtk_sync()
        self.emit('server-disconnected')
        self.main_window.destroy()
        return

    def do_exit_confirm(self):
        self.emit('exit')

    def do_reload_css_style(self):
        if self.style_provider:
            Gtk.StyleContext.remove_provider_for_screen(
                Gdk.Screen.get_default(), self.style_provider)
            self.style_provider = None
        theme_file = self.theme_file
        if theme_file:
            self.style_provider = self.load_style_css(theme_file)

    def do_rpc_cache_clear(self):
        if self.rpc:
            self.rpc.cache_clear()

    def do_server_connected(self):
        self.load_server_config()
        campaign_id = self.config.get('campaign_id')
        if not campaign_id:
            if not self.show_campaign_selection():
                self.logger.debug(
                    'no campaign selected, disconnecting and exiting')
                self.emit('exit')
                return True
        campaign_info = self.rpc.remote_table_row('campaigns',
                                                  self.config['campaign_id'],
                                                  cache=True)
        if campaign_info is None:
            if not self.show_campaign_selection():
                self.logger.debug(
                    'no campaign selected, disconnecting and exiting')
                self.emit('exit')
                return True
            campaign_info = self.rpc.remote_table_row(
                'campaigns',
                self.config['campaign_id'],
                cache=True,
                refresh=True)
        self.config['campaign_name'] = campaign_info.name
        self.emit('campaign-set', self.config['campaign_id'])
        return

    def do_shutdown(self):
        Gtk.Application.do_shutdown(self)
        sys.excepthook = sys.__excepthook__
        self.emit('config-save')

    @property
    def theme_file(self):
        if not self._theme_file:
            return DISABLED
        return find.find_data_file(os.path.join('style', self._theme_file))

    def do_config_load(self, load_defaults):
        """
		Load the client configuration from disk and set the
		:py:attr:`~.KingPhisherClientApplication.config` attribute.

		:param bool load_defaults: Load missing options from the template configuration file.
		"""
        self.logger.info('loading the config from disk')
        client_template = find.find_data_file('client_config.json')
        config_file = os.path.expanduser(self.config_file)
        with open(config_file, 'r') as tmp_file:
            self.config = json_ex.load(tmp_file)
        if load_defaults:
            with open(client_template, 'r') as tmp_file:
                client_template = json_ex.load(tmp_file)
            for key, value in client_template.items():
                if not key in self.config:
                    self.config[key] = value

    def merge_config(self, config_file, strict=True):
        """
		Merge the configuration information from the specified configuration
		file. Only keys which exist in the currently loaded configuration are
		copied over while non-existent keys are skipped. The contents of the new
		configuration overwrites the existing.

		:param bool strict: Do not try remove trailing commas from the JSON data.
		:param str config_file: The path to the configuration file to merge.
		"""
        with open(config_file, 'r') as tmp_file:
            config = json_ex.load(tmp_file, strict=strict)
        if not isinstance(config, dict):
            self.logger.error(
                "can not merge configuration file: {0} (invalid format)".
                format(config_file))
            return
        self.logger.debug(
            'merging configuration information from source file: ' +
            config_file)
        for key, value in config.items():
            if not key in self.config:
                self.logger.warning(
                    "skipped merging non-existent configuration key {0}".
                    format(key))
                continue
            self.config[key] = value
        return

    def load_server_config(self):
        """Load the necessary values from the server's configuration."""
        self.config['server_config'] = self.rpc('config/get', [
            'server.require_id', 'server.secret_id', 'server.tracking_image',
            'server.web_root'
        ])
        return

    def load_style_css(self, css_file):
        self.logger.debug('loading style from css file: ' + css_file)
        css_file = Gio.File.new_for_path(css_file)
        style_provider = Gtk.CssProvider()
        style_provider.connect('parsing-error',
                               self.signal_css_provider_parsing_error)
        try:
            style_provider.load_from_file(css_file)
        except GLib.Error:  # pylint: disable=catching-non-exception
            self.logger.error(
                'there was an error parsing the css file, it will not be applied as a style provider'
            )
            return None
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(), style_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        return style_provider

    def server_connect(self, username, password, otp=None):
        # pylint: disable=too-many-locals
        server_version_info = None
        title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'
        active_window = self.get_active_window()

        server = parse_server(self.config['server'], 22)
        if ipaddress.is_loopback(server[0]):
            local_server = ('localhost', self.config['server_remote_port'])
            self.logger.info("connecting to local king phisher instance")
        else:
            local_server = self._create_ssh_forwarder(server, username,
                                                      password)
        if not local_server:
            return False, ConnectionErrorReason.ERROR_PORT_FORWARD

        rpc = client_rpc.KingPhisherRPCClient(
            local_server, use_ssl=self.config.get('server_use_ssl'))
        if self.config.get('rpc.serializer'):
            try:
                rpc.set_serializer(self.config['rpc.serializer'])
            except ValueError as error:
                self.logger.error(
                    "failed to set the rpc serializer, error: '{0}'".format(
                        error.message))

        generic_message = 'Can not contact the RPC HTTP service, ensure that the '
        generic_message += "King Phisher Server is currently running on port {0}.".format(
            int(self.config['server_remote_port']))
        connection_failed = True
        try:
            server_version_info = rpc('version')
            if server_version_info is None:
                raise RuntimeError(
                    'no version information was retrieved from the server')
        except advancedhttpserver.RPCError as error:
            self.logger.warning(
                'failed to connect to the remote rpc service due to http status: '
                + str(error.status))
            gui_utilities.show_dialog_error(
                title_rpc_error, active_window,
                "The server responded with HTTP status: {0}.".format(
                    str(error.status)))
        except BadStatusLine as error:
            self.logger.warning(
                'failed to connect to the remote rpc service due to http bad status line: '
                + error.line)
            gui_utilities.show_dialog_error(title_rpc_error, active_window,
                                            generic_message)
        except socket.error as error:
            gui_utilities.show_dialog_exc_socket_error(error, active_window)
        except ssl.CertificateError as error:
            self.logger.warning(
                'failed to connect to the remote rpc service with a https certificate error: '
                + error.message)
            gui_utilities.show_dialog_error(
                title_rpc_error, active_window,
                'The server presented an invalid SSL certificate.')
        except Exception:
            self.logger.warning('failed to connect to the remote rpc service',
                                exc_info=True)
            gui_utilities.show_dialog_error(title_rpc_error, active_window,
                                            generic_message)
        else:
            connection_failed = False

        if connection_failed:
            self.emit('server-disconnected')
            return False, ConnectionErrorReason.ERROR_CONNECTION

        server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
        if isinstance(server_rpc_api_version, int):
            # compatibility with pre-0.2.0 version
            server_rpc_api_version = (server_rpc_api_version, 0)
        self.logger.info(
            "successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})"
            .format(server_version_info['version'], server_rpc_api_version[0],
                    server_rpc_api_version[1]))

        error_text = None
        if server_rpc_api_version[0] < version.rpc_api_version.major or (
                server_rpc_api_version[0] == version.rpc_api_version.major
                and server_rpc_api_version[1] < version.rpc_api_version.minor):
            error_text = 'The server is running an old and incompatible version.'
            error_text += '\nPlease update the remote server installation.'
        elif server_rpc_api_version[0] > version.rpc_api_version.major:
            error_text = 'The client is running an old and incompatible version.'
            error_text += '\nPlease update the local client installation.'
        if error_text:
            gui_utilities.show_dialog_error(
                'The RPC API Versions Are Incompatible', active_window,
                error_text)
            self.emit('server-disconnected')
            return False, ConnectionErrorReason.ERROR_INCOMPATIBLE_VERSIONS

        login_result, login_reason = rpc.login(username, password, otp)
        if not login_result:
            self.logger.warning(
                'failed to authenticate to the remote king phisher service, reason: '
                + login_reason)
            self.emit('server-disconnected')
            return False, login_reason
        rpc.username = username
        self.logger.debug(
            'successfully authenticated to the remote king phisher service')

        self.rpc = rpc
        self.emit('server-connected')
        return True, ConnectionErrorReason.SUCCESS

    def do_server_disconnected(self):
        """Clean up the SSH TCP connections and disconnect from the server."""
        if self.rpc is not None:
            try:
                self.rpc('logout')
            except advancedhttpserver.RPCError as error:
                self.logger.warning('failed to logout, rpc error: ' +
                                    error.message)
            self.rpc = None
        if self._ssh_forwarder:
            self._ssh_forwarder.stop()
            self._ssh_forwarder = None
        return

    def show_campaign_graph(self, graph_name):
        """
		Create a new :py:class:`.CampaignGraph` instance and make it into
		a window. *graph_name* must be the name of a valid, exported
		graph provider.

		:param str graph_name: The name of the graph to make a window of.
		"""
        cls = graphs.get_graph(graph_name)
        graph_inst = cls(self, style_context=self.style_context)
        graph_inst.load_graph()
        window = graph_inst.make_window()
        window.show()

    def show_campaign_selection(self):
        """
		Display the campaign selection dialog in a new
		:py:class:`.CampaignSelectionDialog` instance.

		:return: Whether or not a campaign was selected.
		:rtype: bool
		"""
        dialog = dialogs.CampaignSelectionDialog(self)
        return dialog.interact() == Gtk.ResponseType.APPLY

    def show_preferences(self):
        """
		Display a
		:py:class:`.dialogs.configuration.ConfigurationDialog`
		instance and saves the configuration to disk if cancel is not selected.
		"""
        dialog = dialogs.ConfigurationDialog(self)
        if dialog.interact() != Gtk.ResponseType.CANCEL:
            self.emit('config-save')

    def signal_css_provider_parsing_error(self, css_provider, css_section,
                                          gerror):
        file_path = css_section.get_file()
        if file_path:
            file_path = file_path.get_path()
        else:
            file_path = '[ unknown file ]'
        self.logger.error("css parser error ({0}) in {1}:{2}".format(
            gerror.message, file_path,
            css_section.get_start_line() + 1))
        return

    def signal_window_added(self, _, window):
        for action in self.actions.values():
            window.add_action(action)

    def do_sftp_client_start(self):
        """
		Start the client's preferred sftp client application in a new process.
		"""
        if not self.config['sftp_client']:
            gui_utilities.show_dialog_error(
                'Invalid SFTP Configuration', self.get_active_window(),
                'An SFTP client is not configured.\nOne can be configured in the Client Preferences.'
            )
            return False
        command = str(self.config['sftp_client'])
        sftp_bin = shlex.split(command)[0]
        if not which(sftp_bin):
            self.logger.error('could not locate the sftp binary: ' + sftp_bin)
            gui_utilities.show_dialog_error(
                'Invalid SFTP Configuration', self.get_active_window(),
                "Could not find the SFTP binary '{0}'".format(sftp_bin))
            return False
        try:
            command = command.format(
                server=self.config['server'],
                username=self.config['server_username'],
                web_root=self.config['server_config']['server.web_root'])
        except KeyError as error:
            self.logger.error(
                "key error while parsing the sftp command for token: {0}".
                format(error.args[0]))
            gui_utilities.show_dialog_error(
                'Invalid SFTP Configuration', self.get_active_window(),
                "Invalid token '{0}' in the SFTP command.".format(
                    error.args[0]))
            return False
        self.logger.debug("starting sftp client command: {0}".format(command))
        utilities.start_process(command, wait=False)
        return

    def stop_remote_service(self):
        """
		Stop the remote King Phisher server. This will request that the
		server stop processing new requests and exit. This will display
		a confirmation dialog before performing the operation. If the
		remote service is stopped, the client will quit.
		"""
        active_window = self.get_active_window()
        if not gui_utilities.show_dialog_yes_no(
                'Stop The Remote King Phisher Service?', active_window,
                'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'
        ):
            return
        self.rpc('shutdown')
        self.logger.info('the remote king phisher service has been stopped')
        gui_utilities.show_dialog_error(
            'Now Exiting', active_window,
            'The remote service has been stopped.')
        self.quit()
        return

    @property
    def style_context(self):
        window = self.get_active_window() or self.main_window
        if window is None:
            return None
        return window.get_style_context()
Exemple #15
0
class MailSenderThread(threading.Thread):
    """
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
    def __init__(self, config, target_file, rpc, tab=None):
        """
		:param dict config: The King Phisher client configuration.
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
        super(MailSenderThread, self).__init__()
        self.daemon = True
        self.logger = logging.getLogger('KingPhisher.Client.' +
                                        self.__class__.__name__)
        self.config = config
        self.target_file = target_file
        """The name of the target file in CSV format."""
        self.tab = tab
        """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
        self.rpc = rpc
        self._ssh_forwarder = None
        self.smtp_connection = None
        """The :py:class:`smtplib.SMTP` connection instance."""
        self.smtp_server = parse_server(self.config['smtp_server'], 25)
        self.running = threading.Event()
        """A :py:class:`threading.Event` object indicating if emails are being sent."""
        self.paused = threading.Event()
        """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
        self.should_exit = threading.Event()
        self.max_messages_per_minute = float(
            self.config.get('smtp_max_send_rate', 0.0))
        self._mime_attachments = None

    def tab_notify_sent(self, emails_done, emails_total):
        """
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
        if isinstance(self.tab, gui_utilities.GladeGObject):
            GLib.idle_add(lambda x: self.tab.notify_sent(*x),
                          (emails_done, emails_total))

    def tab_notify_status(self, message):
        """
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
        self.logger.info(message.lower())
        if isinstance(self.tab, gui_utilities.GladeGObject):
            GLib.idle_add(self.tab.notify_status, message + '\n')

    def tab_notify_stopped(self):
        """
		Notify the tab that the message sending operation has stopped.
		"""
        if isinstance(self.tab, gui_utilities.GladeGObject):
            GLib.idle_add(self.tab.notify_stopped)

    def server_ssh_connect(self):
        """
		Connect to the remote SMTP server over SSH and configure port
		forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP
		traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
        server = parse_server(self.config['ssh_server'], 22)
        username = self.config['ssh_username']
        password = self.config['ssh_password']
        remote_server = parse_server(self.config['smtp_server'], 25)
        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                remote_server,
                preferred_private_key=self.config.get('ssh_preferred_key'))
            self._ssh_forwarder.start()
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
        except Exception:
            self.logger.warning('failed to connect to the remote ssh server',
                                exc_info=True)
            return ConnectionErrorReason.ERROR_UNKNOWN
        self.logger.info(
            "started ssh port forwarding to the remote smtp server ({0})".
            format(str(self._ssh_forwarder)))
        self.smtp_server = self._ssh_forwarder.local_server
        return ConnectionErrorReason.SUCCESS

    def server_smtp_connect(self):
        """
		Connect to the configured SMTP server.

		:return: The connection status.
		:rtype: bool
		"""
        if self.config.get('smtp_ssl_enable', False):
            SmtpClass = smtplib.SMTP_SSL
        else:
            SmtpClass = smtplib.SMTP
        try:
            self.smtp_connection = SmtpClass(*self.smtp_server, timeout=10)
        except socket.error:
            self.logger.warning(
                'received a socket.error while connecting to the SMTP server')
        except smtplib.SMTPException:
            self.logger.warning(
                'received an SMTPException while connecting to the SMTP server',
                exc_info=True)
        else:
            return True
        return False

    def server_smtp_disconnect(self):
        """Clean up and close the connection to the remote SMTP server."""
        if self.smtp_connection:
            try:
                self.smtp_connection.quit()
            except smtplib.SMTPServerDisconnected:
                pass
            self.smtp_connection = None
            self.tab_notify_status('Disconnected from the SMTP server')

    def server_smtp_reconnect(self):
        """
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
        if self.smtp_connection:
            try:
                self.smtp_connection.quit()
            except smtplib.SMTPServerDisconnected:
                pass
            self.smtp_connection = None
        while not self.server_smtp_connect():
            self.tab_notify_status('Failed to reconnect to the SMTP server')
            if not self.process_pause(True):
                return False
        return True

    def count_messages(self):
        """
		Count the number of targets that will be sent messages.

		:return: The number of targets that will be sent messages.
		:rtype: int
		"""
        return sum(1 for _ in self.iterate_targets())

    def iterate_targets(self):
        target_type = self.config['mailer.target_type']
        if target_type == 'single':
            target_name = self.config['mailer.target_name'].split(' ')
            while len(target_name) < 2:
                target_name.append('')
            target = MessageTarget(
                first_name=target_name[0].strip(),
                last_name=target_name[1].strip(),
                email_address=self.config['mailer.target_email_address'],
                department=None)
            yield target
        elif target_type == 'file':
            target_file_h = open(self.target_file, 'rU')
            csv_reader = csv.DictReader(
                target_file_h,
                ('first_name', 'last_name', 'email_address', 'department'))
            for raw_target in csv_reader:
                department = raw_target['department']
                if department is not None:
                    department = department.strip()
                    if department == '':
                        department = None
                target = MessageTarget(
                    first_name=raw_target['first_name'].strip(),
                    last_name=raw_target['last_name'].strip(),
                    email_address=raw_target['email_address'].strip(),
                    department=department)
                yield target
            target_file_h.close()
        else:
            self.logger.error(
                "the configured target type '{0}' is unsupported".format(
                    target_type))

    def run(self):
        emails_done = 0
        emails_total = self.count_messages()
        max_messages_per_connection = self.config.get(
            'mailer.max_messages_per_connection', 5)
        self.running.set()
        self.should_exit.clear()
        self.paused.clear()
        self._prepare_env()

        emails_total = "{0:,}".format(emails_total)
        sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(
            len(emails_total), emails_total)
        emails_total = int(emails_total.replace(',', ''))
        attachments = self.get_mime_attachments()
        self.logger.debug("loaded {0:,} MIME attachments".format(
            sum((len(attachments.files), len(attachments.images)))))

        for target in self.iterate_targets():
            if not utilities.is_valid_email_address(target.email_address):
                if target.email_address:
                    self.logger.warning('skipping invalid email address: ' +
                                        target.email_address)
                else:
                    self.logger.warning('skipping blank email address')
                continue
            iteration_time = time.time()
            if self.should_exit.is_set():
                self.tab_notify_status('Sending emails cancelled')
                break
            if not self.process_pause():
                break
            if emails_done > 0 and (emails_done % max_messages_per_connection):
                self.server_smtp_reconnect()

            uid = make_uid()
            emails_done += 1
            self.tab_notify_status(
                sending_line.format(emails_done, uid, target.email_address))
            msg = getattr(self, 'create_' +
                          self.config['mailer.message_type'])(target, uid,
                                                              attachments)
            if not self._try_send_message(target.email_address, msg):
                break

            self.tab_notify_sent(emails_done, emails_total)
            campaign_id = self.config['campaign_id']
            self.rpc('campaign/message/new', campaign_id, uid,
                     target.email_address, target.first_name, target.last_name,
                     target.department)

            if self.max_messages_per_minute:
                iteration_time = (time.time() - iteration_time)
                sleep_time = (60.0 / float(
                    self.max_messages_per_minute)) - iteration_time
                while sleep_time > 0:
                    sleep_chunk = min(sleep_time, 0.5)
                    time.sleep(sleep_chunk)
                    if self.should_exit.is_set():
                        break
                    sleep_time -= sleep_chunk

        self._mime_attachments = None

        self.tab_notify_status(
            "Finished sending, successfully sent {0:,} messages.".format(
                emails_done))
        self.server_smtp_disconnect()
        if self._ssh_forwarder:
            self._ssh_forwarder.stop()
            self._ssh_forwarder = None
            self.tab_notify_status('Disconnected from the SSH server')
        self.tab_notify_stopped()
        return

    def process_pause(self, set_pause=False):
        """
		Pause sending emails if a pause request has been set.

		:param bool set_pause: Whether to request a pause before processing it.
		:return: Whether or not the sending operation was cancelled during the pause.
		:rtype: bool
		"""
        if set_pause:
            if isinstance(self.tab, gui_utilities.GladeGObject):
                gui_utilities.glib_idle_add_wait(
                    lambda: self.tab.pause_button.set_property('active', True))
            else:
                self.pause()
        if self.paused.is_set():
            self.tab_notify_status('Paused sending emails, waiting to resume')
            self.running.wait()
            self.paused.clear()
            if self.should_exit.is_set():
                self.tab_notify_status('Sending emails cancelled')
                return False
            self.tab_notify_status('Resuming sending emails')
            self.max_messages_per_minute = float(
                self.config.get('smtp_max_send_rate', 0.0))
        return True

    def create_calendar_invite(self, target, uid, attachments):
        """
		Create a MIME calendar invite to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`Attachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
        top_msg = TopMIMEMultipart('mixed', self.config, target)

        related_msg = mime.multipart.MIMEMultipart('related')
        top_msg.attach(related_msg)

        alt_msg = mime.multipart.MIMEMultipart('alternative')
        related_msg.attach(alt_msg)

        part = mime.base.MIMEBase('text', 'plain', charset='utf-8')
        part.set_payload(
            'This calendar invite requires an HTML enabled viewer.\r\n\r\n')
        encoders.encode_base64(part)
        alt_msg.attach(part)

        with codecs.open(self.config['mailer.html_file'],
                         'r',
                         encoding='utf-8') as file_h:
            msg_template = file_h.read()
        formatted_msg = format_message(msg_template,
                                       self.config,
                                       first_name=target.first_name,
                                       last_name=target.last_name,
                                       uid=uid,
                                       target_email=target.email_address)
        part = mime.text.MIMEText(formatted_msg, 'html', 'utf-8')
        alt_msg.attach(part)

        start_time = get_invite_start_from_config(self.config)
        if self.config['mailer.calendar_invite_all_day']:
            duration = ics.DurationAllDay()
        else:
            duration = int(self.config['mailer.calendar_invite_duration']) * 60
        ical = ics.Calendar(
            self.config['mailer.source_email'],
            start_time,
            self.config.get('mailer.calendar_invite_summary'),
            duration=duration,
            location=self.config.get('mailer.calendar_invite_location'))
        ical.add_attendee(target.email_address,
                          rsvp=self.config.get('mailer.calendar_request_rsvp',
                                               False))

        part = mime.base.MIMEBase('text',
                                  'calendar',
                                  charset='utf-8',
                                  method='REQUEST')
        part.set_payload(str(ical))
        encoders.encode_base64(part)
        alt_msg.attach(part)

        for attach in attachments.images:
            related_msg.attach(attach)

        for attach in attachments.files:
            top_msg.attach(attach)
        return top_msg

    def create_email(self, target, uid, attachments):
        """
		Create a MIME email to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`MessageAttachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
        msg = TopMIMEMultipart('related', self.config, target)
        importance = self.config.get('mailer.importance', 'Normal')
        if importance != 'Normal':
            msg['Importance'] = importance
        sensitivity = self.config.get('mailer.sensitivity', 'Normal')
        if sensitivity != 'Normal':
            msg['Sensitivity'] = sensitivity

        msg_alt = mime.multipart.MIMEMultipart('alternative')
        msg.attach(msg_alt)
        with codecs.open(self.config['mailer.html_file'],
                         'r',
                         encoding='utf-8') as file_h:
            msg_template = file_h.read()
        formatted_msg = format_message(msg_template,
                                       self.config,
                                       first_name=target.first_name,
                                       last_name=target.last_name,
                                       uid=uid,
                                       target_email=target.email_address)
        msg_body = mime.text.MIMEText(formatted_msg, 'html', 'utf-8')
        msg_alt.attach(msg_body)

        # process attachments
        for attach in attachments.files:
            msg.attach(attach)
        for attach in attachments.images:
            msg.attach(attach)
        return msg

    def get_mime_attachments(self):
        """
		Return a :py:class:`.MessageAttachments` object containing both the images and
		raw files to be included in sent messages.

		:return: A namedtuple of both files and images in their MIME containers.
		:rtype: :py:class:`.MessageAttachments`
		"""
        files = []
        if self.config.get('mailer.attachment_file'):
            attachment = self.config['mailer.attachment_file']
            attachfile = mime.base.MIMEBase(*mimetypes.guess_type(attachment))
            attachfile.set_payload(open(attachment, 'rb').read())
            encoders.encode_base64(attachfile)
            attachfile.add_header(
                'Content-Disposition', "attachment; filename=\"{0}\"".format(
                    os.path.basename(attachment)))
            files.append(attachfile)

        images = []
        for attachment_file, attachment_name in template_environment.attachment_images.items(
        ):
            attachfile = mime.image.MIMEImage(
                open(attachment_file, 'rb').read())
            attachfile.add_header('Content-ID',
                                  "<{0}>".format(attachment_name))
            attachfile.add_header(
                'Content-Disposition',
                "inline; filename=\"{0}\"".format(attachment_name))
            images.append(attachfile)
        return MessageAttachments(tuple(files), tuple(images))

    def _prepare_env(self):
        with codecs.open(self.config['mailer.html_file'],
                         'r',
                         encoding='utf-8') as file_h:
            msg_template = file_h.read()
        template_environment.set_mode(template_environment.MODE_ANALYZE)
        format_message(msg_template, self.config, uid=make_uid())
        template_environment.set_mode(template_environment.MODE_SEND)

    def _try_send_message(self, *args, **kwargs):
        message_sent = False
        while not message_sent:
            for _ in range(0, 3):
                try:
                    self.send_message(*args, **kwargs)
                    message_sent = True
                    break
                except smtplib.SMTPException:
                    self.tab_notify_status('Failed to send message')
                    time.sleep(1)
            if not message_sent:
                self.server_smtp_disconnect()
                if not self.process_pause(True):
                    return False
                self.server_smtp_reconnect()
        return True

    def send_message(self, target_email, msg):
        """
		Send an email using the connected SMTP server.

		:param str target_email: The email address to send the message to.
		:param msg: The formatted message to be sent.
		:type msg: :py:class:`.mime.multipart.MIMEMultipart`
		"""
        source_email = self.config['mailer.source_email_smtp']
        self.smtp_connection.sendmail(source_email, target_email,
                                      msg.as_string())

    def pause(self):
        """
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is paused.
		"""
        self.running.clear()
        self.paused.set()

    def unpause(self):
        """
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is no longer paused.
		"""
        self.running.set()

    def stop(self):
        """
		Requests that the email sending operation stop. It can not be
		resumed from the same position. This function blocks until the
		stop request has been processed and the thread exits.
		"""
        self.should_exit.set()
        self.unpause()
        if self.is_alive():
            self.join()

    def missing_files(self):
        """
		Return a list of all missing or unreadable files which are referenced by
		the message template.

		:return: The list of unusable files.
		:rtype: list
		"""
        missing = []
        attachment = self.config.get('mailer.attachment_file')
        if attachment and not os.access(attachment, os.R_OK):
            missing.append(attachment)
        msg_template = self.config['mailer.html_file']
        if not os.access(msg_template, os.R_OK):
            missing.append(msg_template)
            return missing
        self._prepare_env()
        for attachment in template_environment.attachment_images.keys():
            if not os.access(attachment, os.R_OK):
                missing.append(attachment)
        return missing
class KingPhisherClientApplication(_Gtk_Application):
	"""
	This is the top level King Phisher client object. It contains the
	custom GObject signals, keeps all the GUI references, and manages
	the RPC client object. This is also the parent window for most
	GTK objects.

	:GObject Signals: :ref:`gobject-signals-application-label`
	"""
	__gsignals__ = {
		'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
		'exit': (GObject.SIGNAL_RUN_LAST, None, ()),
		'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()),
		'reload-css-style': (GObject.SIGNAL_RUN_FIRST, None, ()),
		'rpc-cache-clear': (GObject.SIGNAL_RUN_FIRST, None, ()),
		'server-connected': (GObject.SIGNAL_RUN_LAST, None, ())
	}
	def __init__(self, config_file=None):
		super(KingPhisherClientApplication, self).__init__()
		self.logger = logging.getLogger('KingPhisher.Client.Application')
		# log version information for debugging purposes
		self.logger.debug("gi.repository GLib version: {0}".format('.'.join(map(str, GLib.glib_version))))
		self.logger.debug("gi.repository GObject version: {0}".format('.'.join(map(str, GObject.pygobject_version))))
		self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format(Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()))
		if tools.has_vte:
			self.logger.debug("gi.repository VTE version: {0}".format(tools.Vte._version))
		if graphs.has_matplotlib:
			self.logger.debug("matplotlib version: {0}".format(graphs.matplotlib.__version__))
		self.set_property('application-id', 'org.king-phisher.client')
		self.set_property('register-session', True)
		self.config_file = (config_file or CONFIG_FILE_PATH)
		"""The file containing the King Phisher client configuration."""
		self.config = None
		"""The primary King Phisher client configuration."""
		self.main_window = None
		"""The primary top-level :py:class:`~.MainApplicationWindow` instance."""
		self.rpc = None
		"""The :py:class:`~.KingPhisherRPCClient` instance for the application."""
		self._ssh_forwarder = None
		"""The SSH forwarder responsible for tunneling RPC communications."""
		self.style_provider = None
		try:
			self.load_config(load_defaults=True)
		except IOError:
			self.logger.critical('failed to load the client configuration')
			raise

		self.connect('window-added', self.signal_window_added)
		self.actions = {}
		self._create_actions()

	def _create_actions(self):
		action = Gio.SimpleAction.new('emit-application-signal', GLib.VariantType.new('s'))
		action.connect('activate', self.action_emit_application_signal)
		accelerators = (
			('<Control><Shift>F1', 'rpc-cache-clear'),
			('<Control><Shift>F12', 'reload-css-style')
		)
		for key, signal_name in accelerators:
			if Gtk.check_version(3, 14, 0):
				self.add_accelerator(key, 'win.emit-application-signal', GLib.Variant.new_string(signal_name))
			else:
				self.set_accels_for_action("win.emit-application-signal('{0}')".format(signal_name), (key,))
		self.actions['emit-application-signal'] = action
		self.add_action(action)

	def _create_ssh_forwarder(self, server, username, password):
		"""
		Create and set the
		:py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
		active_window = self.get_active_window()
		title_ssh_error = 'Failed To Connect To The SSH Service'
		server_remote_port = self.config['server_remote_port']
		local_port = random.randint(2000, 6000)

		try:
			self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key'])
			self._ssh_forwarder.start()
			time.sleep(0.5)
			self.logger.info('started ssh port forwarding')
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.')
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error)
		except Exception as error:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
			gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__))
		else:
			return local_port
		self.server_disconnect()
		return

	def campaign_configure(self):
		assistant = assistants.CampaignAssistant(self, campaign_id=self.config['campaign_id'])
		assistant.assistant.set_transient_for(self.get_active_window())
		assistant.assistant.set_modal(True)
		assistant.interact()

	def campaign_delete(self):
		"""
		Delete the campaign on the server. A confirmation dialog will be
		displayed before the operation is performed. If the campaign is
		deleted and a new campaign is not selected with
		:py:meth:`.show_campaign_selection`, the client will quit.
		"""
		if not gui_utilities.show_dialog_yes_no('Delete This Campaign?', self.get_active_window(), 'This action is irreversible, all campaign data will be lost.'):
			return
		self.rpc('db/table/delete', 'campaigns', self.config['campaign_id'])
		if not self.show_campaign_selection():
			gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'A campaign must be selected.')
			self.quit()

	def campaign_rename(self):
		"""
		Show a dialog prompting the user to for the a new name to assign to the
		currently selected campaign.
		"""
		campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id'])
		prompt = dialogs.TextEntryDialog.build_prompt(self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name)
		response = prompt.interact()
		if response == None or response == campaign.name:
			return
		self.rpc('db/table/set', 'campaigns', self.config['campaign_id'], 'name', response)
		gui_utilities.show_dialog_info('Campaign Name Updated', self.get_active_window(), 'The campaign name was successfully changed.')

	def exception_hook(self, exc_type, exc_value, exc_traceback):
		if isinstance(exc_value, KeyboardInterrupt):
			self.logger.warning('received a KeyboardInterrupt exception')
			return
		exc_info = (exc_type, exc_value, exc_traceback)
		error_uid = str(uuid.uuid4())
		self.logger.error("error uid: {0} an unhandled exception was thrown".format(error_uid), exc_info=exc_info)
		dialogs.ExceptionDialog(self, exc_info=exc_info, error_uid=error_uid).interact()

	def get_active_window(self):
		# work around for Gtk < 3.6
		super_cls = super(KingPhisherClientApplication, self)
		if not hasattr(super_cls, 'get_active_window'):
			return None
		return super_cls.get_active_window()

	def quit(self, optional=False):
		"""
		Quit the client and perform any necessary clean up operations. If
		*optional* is False then the exit-confirm signal will not be sent and
		there will not be any opportunities for the client to cancel the
		operation.

		:param bool optional: Whether the quit is request is optional or not.
		"""
		self.emit('exit-confirm' if optional else 'exit')

	def action_emit_application_signal(self, _, signal_name):
		signal_name = signal_name.get_string()
		self.logger.debug('action emit-application-signal invoked for ' + signal_name)
		self.emit(signal_name)

	def do_activate(self):
		Gtk.Application.do_activate(self)
		sys.excepthook = self.exception_hook

		# load a custom css file if one is available
		css_file = find.find_data_file('king-phisher-client.css')
		if css_file:
			self.style_provider = self.load_style_css(css_file)
		else:
			self.logger.debug('no custom css file was found')

		# create and show the main window
		self.main_window = windows.MainApplicationWindow(self.config, self)
		self.main_window.set_position(Gtk.WindowPosition.CENTER)
		self.main_window.show()

	def do_campaign_set(self, campaign_id):
		self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id']))
		self.emit('rpc-cache-clear')

	def do_exit(self):
		self.main_window.hide()
		gui_utilities.gtk_widget_destroy_children(self.main_window)
		gui_utilities.gtk_sync()
		self.server_disconnect()
		self.main_window.destroy()
		return

	def do_exit_confirm(self):
		self.emit('exit')

	def do_reload_css_style(self):
		if self.style_provider:
			Gtk.StyleContext.remove_provider_for_screen(
				Gdk.Screen.get_default(),
				self.style_provider
			)
			self.style_provider = None
		css_file = find.find_data_file('king-phisher-client.css')
		if css_file:
			self.style_provider = self.load_style_css(css_file)

	def do_rpc_cache_clear(self):
		if self.rpc:
			self.rpc.cache_clear()

	def do_server_connected(self):
		self.load_server_config()
		campaign_id = self.config.get('campaign_id')
		if campaign_id is None:
			if not self.show_campaign_selection():
				self.logger.debug('no campaign selected, disconnecting and exiting')
				self.emit('exit')
				return True
		campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True)
		if campaign_info is None:
			if not self.show_campaign_selection():
				self.logger.debug('no campaign selected, disconnecting and exiting')
				self.emit('exit')
				return True
			campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True)
		self.config['campaign_name'] = campaign_info.name
		self.emit('campaign-set', self.config['campaign_id'])
		return

	def do_shutdown(self):
		Gtk.Application.do_shutdown(self)
		sys.excepthook = sys.__excepthook__
		self.save_config()

	def load_config(self, load_defaults=False):
		"""
		Load the client configuration from disk and set the
		:py:attr:`~.KingPhisherClientApplication.config` attribute.

		:param bool load_defaults: Load missing options from the template configuration file.
		"""
		self.logger.info('loading the config from disk')
		config_file = os.path.expanduser(self.config_file)
		client_template = find.find_data_file('client_config.json')
		if not (os.path.isfile(config_file) and os.stat(config_file).st_size):
			shutil.copy(client_template, config_file)
		with open(config_file, 'r') as tmp_file:
			self.config = json.load(tmp_file)
		if load_defaults:
			with open(client_template, 'r') as tmp_file:
				client_template = json.load(tmp_file)
			for key, value in client_template.items():
				if not key in self.config:
					self.config[key] = value

	def load_server_config(self):
		"""Load the necessary values from the server's configuration."""
		self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root'])
		return

	def load_style_css(self, css_file):
		self.logger.debug('loading style from css file: ' + css_file)
		css_file = Gio.File.new_for_path(css_file)
		style_provider = Gtk.CssProvider()
		style_provider.connect('parsing-error', self.signal_css_provider_parsing_error)
		try:
			style_provider.load_from_file(css_file)
		except GLib.Error:
			self.logger.error('there was an error parsing the css file, it will not be applied as a style provider')
			return None
		Gtk.StyleContext.add_provider_for_screen(
			Gdk.Screen.get_default(),
			style_provider,
			Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
		)
		return style_provider

	def save_config(self):
		"""Write the client configuration to disk."""
		self.logger.info('writing the client configuration to disk')
		config = copy.copy(self.config)
		for key in self.config.keys():
			if 'password' in key or key == 'server_config':
				del config[key]
		with open(os.path.expanduser(self.config_file), 'w') as config_file_h:
			json.dump(config, config_file_h, sort_keys=True, indent=2, separators=(',', ': '))

	def server_connect(self):
		server_version_info = None
		title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'
		active_window = self.get_active_window()

		server = parse_server(self.config['server'], 22)
		username = self.config['server_username']
		password = self.config['server_password']
		if server[0] == 'localhost' or (utilities.is_valid_ip_address(server[0]) and ipaddress.ip_address(server[0]).is_loopback):
			local_port = self.config['server_remote_port']
			self.logger.info("connecting to local king-phisher instance")
		else:
			local_port = self._create_ssh_forwarder(server, username, password)
		if not local_port:
			return

		self.rpc = client_rpc.KingPhisherRPCClient(('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl'))
		if self.config.get('rpc.serializer'):
			try:
				self.rpc.set_serializer(self.config['rpc.serializer'])
			except ValueError as error:
				self.logger.error("failed to set the rpc serializer, error: '{0}'".format(error.message))

		connection_failed = True
		try:
			assert self.rpc('client/initialize')
			server_version_info = self.rpc('version')
			assert server_version_info != None
		except AdvancedHTTPServerRPCError as err:
			if err.status == 401:
				self.logger.warning('failed to authenticate to the remote king phisher service')
				gui_utilities.show_dialog_error(title_rpc_error, active_window, 'The server responded that the credentials are invalid.')
			else:
				self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status))
				gui_utilities.show_dialog_error(title_rpc_error, active_window, 'The server responded with HTTP status: ' + str(err.status))
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, active_window)
		except Exception as error:
			self.logger.warning('failed to connect to the remote rpc service', exc_info=True)
			gui_utilities.show_dialog_error(title_rpc_error, active_window, 'Ensure that the King Phisher Server is currently running.')
		else:
			connection_failed = False
		finally:
			if connection_failed:
				self.rpc = None
				self.server_disconnect()
				return

		server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
		if isinstance(server_rpc_api_version, int):
			# compatibility with pre-0.2.0 version
			server_rpc_api_version = (server_rpc_api_version, 0)
		self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})".format(server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1]))

		error_text = None
		if server_rpc_api_version[0] < version.rpc_api_version.major or (server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor):
			error_text = 'The server is running an old and incompatible version.'
			error_text += '\nPlease update the remote server installation.'
		elif server_rpc_api_version[0] > version.rpc_api_version.major:
			error_text = 'The client is running an old and incompatible version.'
			error_text += '\nPlease update the local client installation.'
		if error_text:
			gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', active_window, error_text)
			self.server_disconnect()
			return
		self.emit('server-connected')
		return

	def server_disconnect(self):
		"""Clean up the SSH TCP connections and disconnect from the server."""
		if self._ssh_forwarder:
			self._ssh_forwarder.stop()
			self._ssh_forwarder = None
			self.logger.info('stopped ssh port forwarding')
		self.rpc = None
		return

	def show_campaign_graph(self, graph_name):
		"""
		Create a new :py:class:`.CampaignGraph` instance and make it into
		a window. *graph_name* must be the name of a valid, exported
		graph provider.

		:param str graph_name: The name of the graph to make a window of.
		"""
		cls = graphs.get_graph(graph_name)
		graph_inst = cls(self)
		graph_inst.load_graph()
		window = graph_inst.make_window()
		window.show()

	def show_campaign_selection(self):
		"""
		Display the campaign selection dialog in a new
		:py:class:`.CampaignSelectionDialog` instance.

		:return: Whether or not a campaign was selected.
		:rtype: bool
		"""
		dialog = dialogs.CampaignSelectionDialog(self)
		return dialog.interact() == Gtk.ResponseType.APPLY

	def show_preferences(self):
		"""
		Display a
		:py:class:`.dialogs.configuration.ConfigurationDialog`
		instance and saves the configuration to disk if cancel is not selected.
		"""
		dialog = dialogs.ConfigurationDialog(self)
		if dialog.interact() != Gtk.ResponseType.CANCEL:
			self.save_config()

	def signal_css_provider_parsing_error(self, css_provider, css_section, gerror):
		file_path = css_section.get_file()
		if file_path:
			file_path = file_path.get_path()
		else:
			file_path = '[ unknown file ]'
		self.logger.error("css parser error ({0}) in {1}:{2}".format(gerror.message, file_path, css_section.get_start_line()))
		return

	def signal_window_added(self, _, window):
		for action in self.actions.values():
			window.add_action(action)

	def start_sftp_client(self):
		"""
		Start the client's preferred sftp client application in a new process.
		"""
		if not self.config['sftp_client']:
			gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), 'An SFTP client is not configured.\nOne can be configured in the Client Preferences.')
			return False
		command = str(self.config['sftp_client'])
		sftp_bin = shlex.split(command)[0]
		if not which(sftp_bin):
			self.logger.error('could not locate the sftp binary: ' + sftp_bin)
			gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Could not find the SFTP binary '{0}'".format(sftp_bin))
			return False
		try:
			command = command.format(
				server=self.config['server'],
				username=self.config['server_username'],
				web_root=self.config['server_config']['server.web_root']
			)
		except KeyError as error:
			self.logger.error("key error while parsing the sftp command for token: {0}".format(error.args[0]))
			gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Invalid token '{0}' in the SFTP command.".format(error.args[0]))
			return False
		self.logger.debug("starting sftp client command: {0}".format(command))
		utilities.start_process(command, wait=False)
		return

	def stop_remote_service(self):
		"""
		Stop the remote King Phisher server. This will request that the
		server stop processing new requests and exit. This will display
		a confirmation dialog before performing the operation. If the
		remote service is stopped, the client will quit.
		"""
		if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', self.get_active_window(), 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'):
			return
		self.rpc('shutdown')
		self.logger.info('the remote king phisher service has been stopped')
		gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'The remote service has been stopped.')
		self.quit()
		return
	def server_connect(self):
		import socket
		server_version_info = None
		while True:
			if self.ssh_forwarder:
				self.ssh_forwarder.stop()
				self.ssh_forwarder = None
				self.logger.info('stopped ssh port forwarding')
			login_dialog = KingPhisherClientLoginDialog(self.config, self)
			login_dialog.objects_load_from_config()
			response = login_dialog.interact()
			if response == Gtk.ResponseType.CANCEL:
				return False
			server = utilities.server_parse(self.config['server'], 22)
			username = self.config['server_username']
			password = self.config['server_password']
			server_remote_port = self.config.get('server_remote_port', 80)
			local_port = random.randint(2000, 6000)
			try:
				self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config.get('ssh_preferred_key'))
				self.ssh_forwarder.start()
				time.sleep(0.5)
				self.logger.info('started ssh port forwarding')
			except paramiko.AuthenticationException:
				self.logger.warning('failed to authenticate to the remote ssh server')
				gui_utilities.show_dialog_error('Invalid Credentials', self)
				continue
			except:
				self.logger.warning('failed to connect to the remote ssh server')
				gui_utilities.show_dialog_error('Failed To Connect To The SSH Service', self)
				continue
			self.rpc = KingPhisherRPCClient(('localhost', local_port), username=username, password=password)
			try:
				server_version_info = self.rpc('version')
				assert(self.rpc('client/initialize'))
			except AdvancedHTTPServerRPCError as err:
				if err.status == 401:
					self.logger.warning('failed to authenticate to the remote king phisher service')
					gui_utilities.show_dialog_error('Invalid Credentials', self)
				else:
					self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status))
					gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self, 'The server responded with HTTP status: ' + str(err.status))
				continue
			except:
				self.logger.warning('failed to connect to the remote rpc service')
				gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self)
				continue
			break
		assert(server_version_info != None)
		server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
		self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1})".format(server_version_info['version'], server_rpc_api_version))
		self.server_local_port = local_port
		if server_rpc_api_version != version.rpc_api_version:
			if version.rpc_api_version < server_rpc_api_version:
				secondary_text = 'The local client is not up to date with the server version.'
			else:
				secondary_text = 'The remote server is not up to date with the client version.'
			secondary_text += '\nPlease ensure that both the client and server are fully up to date.'
			gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', self, secondary_text)
			self.server_disconnect()
			return False
		return True
Exemple #18
0
    def server_connect(self):
        """
		Perform the connection setup as part of the server connection
		initialization process. This will display a GUI window requesting
		the connection information. An :py:class:`.SSHTCPForwarder` instance
		is created and configured for tunneling traffic to the King Phisher
		server. This also verifies that the RPC API version running on
		the server is compatible with the client.

		:return: Whether or not the connection attempt was successful.
		:rtype: bool
		"""
        server_version_info = None
        title_ssh_error = 'Failed To Connect To The SSH Service'
        title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'
        while True:
            if self.ssh_forwarder:
                self.ssh_forwarder.stop()
                self.ssh_forwarder = None
                self.logger.info('stopped ssh port forwarding')
            login_dialog = dialogs.KingPhisherClientLoginDialog(
                self.config, self)
            login_dialog.objects_load_from_config()
            response = login_dialog.interact()
            if response == Gtk.ResponseType.CANCEL:
                return False
            server = utilities.server_parse(self.config['server'], 22)
            username = self.config['server_username']
            password = self.config['server_password']
            server_remote_port = self.config['server_remote_port']
            local_port = random.randint(2000, 6000)
            try:
                self.ssh_forwarder = SSHTCPForwarder(
                    server,
                    username,
                    password,
                    local_port, ('127.0.0.1', server_remote_port),
                    preferred_private_key=self.config['ssh_preferred_key'])
                self.ssh_forwarder.start()
                time.sleep(0.5)
                self.logger.info('started ssh port forwarding')
            except paramiko.AuthenticationException:
                self.logger.warning(
                    'failed to authenticate to the remote ssh server')
                gui_utilities.show_dialog_error(
                    title_ssh_error, self,
                    'The server responded that the credentials are invalid')
                continue
            except socket.error as error:
                error_number, error_message = error.args
                if error_number == 111:
                    gui_utilities.show_dialog_error(
                        title_ssh_error, self,
                        'The server refused the connection')
                else:
                    gui_utilities.show_dialog_error(
                        title_ssh_error, self,
                        "Socket error #{0} ({1})".format(
                            (error_number or 'NOT-SET'), error_message))
                continue
            except Exception:
                self.logger.warning(
                    'failed to connect to the remote ssh server')
                gui_utilities.show_dialog_error(title_ssh_error, self)
                continue
            self.rpc = KingPhisherRPCClient(
                ('localhost', local_port),
                username=username,
                password=password,
                use_ssl=self.config.get('server_use_ssl'))
            if self.config.get('rpc.serializer'):
                try:
                    self.rpc.set_serializer(self.config['rpc.serializer'])
                except ValueError as error:
                    self.logger.error(
                        "failed to set the rpc serializer, error: '{0}'".
                        format(error.message))
            try:
                assert (self.rpc('client/initialize'))
                server_version_info = self.rpc('version')
            except AdvancedHTTPServerRPCError as err:
                if err.status == 401:
                    self.logger.warning(
                        'failed to authenticate to the remote king phisher service'
                    )
                    gui_utilities.show_dialog_error(
                        title_rpc_error, self,
                        'The server responded that the credentials are invalid'
                    )
                else:
                    self.logger.warning(
                        'failed to connect to the remote rpc server with http status: '
                        + str(err.status))
                    gui_utilities.show_dialog_error(
                        title_rpc_error, self,
                        'The server responded with HTTP status: ' +
                        str(err.status))
                continue
            except:
                self.logger.warning(
                    'failed to connect to the remote rpc service')
                gui_utilities.show_dialog_error(
                    title_rpc_error, self,
                    'Ensure that the King Phisher Server is currently running')
                continue
            break
        assert (server_version_info != None)
        server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
        self.logger.info(
            "successfully connected to the king phisher server (version: {0} rpc api version: {1})"
            .format(server_version_info['version'], server_rpc_api_version))
        self.server_local_port = local_port
        if server_rpc_api_version != version.rpc_api_version:
            if version.rpc_api_version < server_rpc_api_version:
                secondary_text = 'The local client is not up to date with the server version.'
            else:
                secondary_text = 'The remote server is not up to date with the client version.'
            secondary_text += '\nPlease ensure that both the client and server are fully up to date.'
            gui_utilities.show_dialog_error(
                'The RPC API Versions Are Incompatible', self, secondary_text)
            self.server_disconnect()
            return False
        return True
Exemple #19
0
class KingPhisherClient(_Gtk_ApplicationWindow):
    """
	This is the top level King Phisher client object. It contains the
	custom GObject signals, keeps all the GUI references, and manages
	the RPC client object. This is also the parent window for most
	GTK objects.

	:GObject Signals: :ref:`gobject-signals-kingphisher-client-label`
	"""
    __gsignals__ = {
        'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str, )),
        'exit': (GObject.SIGNAL_RUN_LAST, None, ()),
        'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()),
        'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ())
    }

    def __init__(self, config, application):
        """
		:param dict config: The main King Phisher client configuration.
		:param application: The application instance to which this window belongs.
		:type application: :py:class:`.KingPhisherClientApplication`
		"""
        assert isinstance(application, Gtk.Application)
        super(KingPhisherClient, self).__init__(application=application)
        self.application = application
        self.logger = logging.getLogger('KingPhisher.Client.MainWindow')
        self.config = config
        """The main King Phisher client configuration."""
        self._ssh_forwarder = None
        self.set_property('title', 'King Phisher')
        vbox = Gtk.Box()
        vbox.set_property('orientation', Gtk.Orientation.VERTICAL)
        vbox.show()
        self.add(vbox)
        default_icon_file = find.find_data_file('king-phisher-icon.svg')
        if default_icon_file:
            icon_pixbuf = GdkPixbuf.Pixbuf.new_from_file(default_icon_file)
            self.set_default_icon(icon_pixbuf)
        action_group = Gtk.ActionGroup(name="client_window_actions")
        self._add_menu_actions(action_group)
        uimanager = self._create_ui_manager()
        self._add_menu_optional_actions(action_group, uimanager)
        self.add_accel_group(uimanager.get_accel_group())
        uimanager.insert_action_group(action_group)
        self.uimanager = uimanager
        menubar = uimanager.get_widget("/MenuBar")
        vbox.pack_start(menubar, False, False, 0)

        # create notebook and tabs
        self.notebook = Gtk.Notebook()
        """The primary :py:class:`Gtk.Notebook` that holds the top level taps of the client GUI."""
        self.notebook.connect('switch-page', self.signal_notebook_switch_page)
        self.notebook.set_scrollable(True)
        vbox.pack_start(self.notebook, True, True, 0)

        self.tabs = {}
        current_page = self.notebook.get_current_page()
        self.last_page_id = current_page

        mailer_tab = MailSenderTab(self.config, self, self.application)
        self.tabs['mailer'] = mailer_tab
        self.notebook.insert_page(mailer_tab.box, mailer_tab.label,
                                  current_page + 1)
        self.notebook.set_current_page(current_page + 1)

        campaign_tab = CampaignViewTab(self.config, self, self.application)
        campaign_tab.box.show()
        self.tabs['campaign'] = campaign_tab
        self.notebook.insert_page(campaign_tab.box, campaign_tab.label,
                                  current_page + 2)

        self.set_size_request(800, 600)
        self.connect('delete-event', self.signal_delete_event)
        self.notebook.show()
        self.show()
        self.rpc = None  # needs to be initialized last
        """The :py:class:`.KingPhisherRPCClient` instance."""

        login_dialog = dialogs.LoginDialog(self.config, self)
        login_dialog.dialog.connect('response',
                                    self.signal_login_dialog_response,
                                    login_dialog)
        login_dialog.dialog.show()

    def _add_menu_actions(self, action_group):
        # File Menu Actions
        action = Gtk.Action(name='FileMenu',
                            label='File',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='FileOpenCampaign',
                            label='_Open Campaign',
                            tooltip='Open a Campaign',
                            stock_id=Gtk.STOCK_NEW)
        action.connect('activate', lambda x: self.show_campaign_selection())
        action_group.add_action_with_accel(action, '<control>O')

        action = Gtk.Action(name='FileImportMenu',
                            label='Import',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='FileImportMessageConfiguration',
                            label='Message Configuration',
                            tooltip='Message Configuration',
                            stock_id=None)
        action.connect('activate',
                       lambda x: self.tabs['mailer'].import_message_data())
        action_group.add_action(action)

        action = Gtk.Action(name='FileExportMenu',
                            label='Export',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='FileExportCampaignXML',
                            label='Campaign XML',
                            tooltip='Campaign XML',
                            stock_id=None)
        action.connect('activate', lambda x: self.export_campaign_xml())
        action_group.add_action(action)

        action = Gtk.Action(name='FileExportMessageConfiguration',
                            label='Message Configuration',
                            tooltip='Message Configuration',
                            stock_id=None)
        action.connect('activate',
                       lambda x: self.tabs['mailer'].export_message_data())
        action_group.add_action(action)

        action = Gtk.Action(name='FileQuit',
                            label=None,
                            tooltip=None,
                            stock_id=Gtk.STOCK_QUIT)
        action.connect('activate', lambda x: self.emit('exit-confirm'))
        action_group.add_action_with_accel(action, '<control>Q')

        # Edit Menu Actions
        action = Gtk.Action(name='EditMenu',
                            label='Edit',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='EditPreferences',
                            label='Preferences',
                            tooltip='Edit Preferences',
                            stock_id=Gtk.STOCK_EDIT)
        action.connect('activate', lambda x: self.edit_preferences())
        action_group.add_action(action)

        action = Gtk.Action(name='EditDeleteCampaign',
                            label='Delete Campaign',
                            tooltip='Delete Campaign',
                            stock_id=None)
        action.connect('activate', lambda x: self.delete_campaign())
        action_group.add_action(action)

        action = Gtk.Action(name='EditRenameCampaign',
                            label='Rename Campaign',
                            tooltip='Rename Campaign',
                            stock_id=None)
        action.connect('activate', lambda x: self.rename_campaign())
        action_group.add_action(action)

        action = Gtk.Action(name='EditStopService',
                            label='Stop Service',
                            tooltip='Stop The Remote King-Phisher Service',
                            stock_id=None)
        action.connect('activate', lambda x: self.stop_remote_service())
        action_group.add_action(action)

        # Tools Menu Action
        action = Gtk.Action(name='ToolsMenu',
                            label='Tools',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='ToolsRPCTerminal',
                            label='RPC Terminal',
                            tooltip='RPC Terminal',
                            stock_id=None)
        action.connect(
            'activate', lambda x: tools.KingPhisherClientRPCTerminal(
                self.config, self, self.get_property('application')))
        action_group.add_action_with_accel(action, '<control>F1')

        action = Gtk.Action(name='ToolsCloneWebPage',
                            label='Clone Web Page',
                            tooltip='Clone A Web Page',
                            stock_id=None)
        action.connect(
            'activate',
            lambda x: dialogs.ClonePageDialog(self.config, self).interact())
        action_group.add_action(action)

        # Help Menu Actions
        action = Gtk.Action(name='HelpMenu',
                            label='Help',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='HelpAbout',
                            label='About',
                            tooltip='About',
                            stock_id=None)
        action.connect(
            'activate',
            lambda x: dialogs.AboutDialog(self.config, self).interact())
        action_group.add_action(action)

        action = Gtk.Action(name='HelpWiki',
                            label='Wiki',
                            tooltip='Wiki',
                            stock_id=None)
        action.connect(
            'activate', lambda x: utilities.open_uri(
                'https://github.com/securestate/king-phisher/wiki'))
        action_group.add_action(action)

    def _add_menu_optional_actions(self, action_group, uimanager):
        if sys.platform.startswith('linux'):
            action = Gtk.Action(name='ToolsSFTPClient',
                                label='SFTP Client',
                                tooltip='SFTP Client',
                                stock_id=None)
            action.connect('activate', lambda x: self.start_sftp_client())
            action_group.add_action_with_accel(action, '<control>F2')
            merge_id = uimanager.new_merge_id()
            uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsSFTPClient',
                             'ToolsSFTPClient', Gtk.UIManagerItemType.MENUITEM,
                             False)

        if graphs.has_matplotlib:
            action = Gtk.Action(name='ToolsGraphMenu',
                                label='Create Graph',
                                tooltip='Create A Graph',
                                stock_id=None)
            action_group.add_action(action)

            for graph_name in graphs.get_graphs():
                action_name = 'ToolsGraph' + graph_name
                graph = graphs.get_graph(graph_name)
                action = Gtk.Action(name=action_name,
                                    label=graph.name_human,
                                    tooltip=graph.name_human,
                                    stock_id=None)
                action.connect('activate',
                               self.signal_activate_popup_menu_create_graph,
                               graph_name)
                action_group.add_action(action)

            merge_id = uimanager.new_merge_id()
            uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu',
                             'ToolsGraphMenu', Gtk.UIManagerItemType.MENU,
                             False)
            for graph_name in sorted(
                    graphs.get_graphs(),
                    key=lambda gn: graphs.get_graph(gn).name_human):
                action_name = 'ToolsGraph' + graph_name
                uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu',
                                 action_name, action_name,
                                 Gtk.UIManagerItemType.MENUITEM, False)

    def _create_ssh_forwarder(self, server, username, password):
        """
		Create and set the :py:attr:`~.KingPhisherClient._ssh_forwarder`
		attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
        title_ssh_error = 'Failed To Connect To The SSH Service'
        server_remote_port = self.config['server_remote_port']
        local_port = random.randint(2000, 6000)

        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                local_port, ('127.0.0.1', server_remote_port),
                preferred_private_key=self.config['ssh_preferred_key'])
            self._ssh_forwarder.start()
            time.sleep(0.5)
            self.logger.info('started ssh port forwarding')
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            gui_utilities.show_dialog_error(
                title_ssh_error, self,
                'The server responded that the credentials are invalid.')
        except socket.error as error:
            gui_utilities.show_dialog_exc_socket_error(error,
                                                       self,
                                                       title=title_ssh_error)
        except Exception:
            self.logger.warning('failed to connect to the remote ssh server')
            gui_utilities.show_dialog_error(title_ssh_error, self,
                                            'An unknown error occurred.')
        else:
            return local_port
        self.server_disconnect()
        return

    def _create_ui_manager(self):
        uimanager = Gtk.UIManager()
        with open(find.find_data_file(
                'ui_info/client_window.xml')) as ui_info_file:
            ui_data = ui_info_file.read()
        uimanager.add_ui_from_string(ui_data)
        return uimanager

    def signal_activate_popup_menu_create_graph(self, _, graph_name):
        return self.show_campaign_graph(graph_name)

    def signal_notebook_switch_page(self, notebook, current_page, index):
        #previous_page = notebook.get_nth_page(self.last_page_id)
        self.last_page_id = index
        mailer_tab = self.tabs.get('mailer')
        campaign_tab = self.tabs.get('campaign')

        notebook = None
        if mailer_tab and current_page == mailer_tab.box:
            notebook = mailer_tab.notebook
        elif campaign_tab and current_page == campaign_tab.box:
            notebook = campaign_tab.notebook

        if notebook:
            index = notebook.get_current_page()
            notebook.emit('switch-page', notebook.get_nth_page(index), index)

    def signal_delete_event(self, x, y):
        self.emit('exit-confirm')
        return True

    def do_campaign_set(self, campaign_id):
        self.rpc.cache_clear()
        self.logger.info("campaign set to {0} (id: {1})".format(
            self.config['campaign_name'], self.config['campaign_id']))

    def do_exit(self):
        self.hide()
        gui_utilities.gtk_widget_destroy_children(self)
        gui_utilities.gtk_sync()
        self.server_disconnect()
        self.destroy()
        return

    def do_exit_confirm(self):
        self.emit('exit')

    def do_server_connected(self):
        self.load_server_config()
        campaign_id = self.config.get('campaign_id')
        if campaign_id == None:
            if not self.show_campaign_selection():
                self.logger.debug(
                    'no campaign selected, disconnecting and exiting')
                self.emit('exit')
                return True
        campaign_info = self.rpc.remote_table_row('campaigns',
                                                  self.config['campaign_id'],
                                                  cache=True)
        if campaign_info == None:
            if not self.show_campaign_selection():
                self.logger.debug(
                    'no campaign selected, disconnecting and exiting')
                self.emit('exit')
                return True
            campaign_info = self.rpc.remote_table_row(
                'campaigns',
                self.config['campaign_id'],
                cache=True,
                refresh=True)
        self.config['campaign_name'] = campaign_info.name
        self.emit('campaign-set', self.config['campaign_id'])
        return

    def client_quit(self):
        """
		Unconditionally quit the client and perform any necessary clean up
		operations. The exit-confirm signal will not be sent so there will not
		be any opportunities for the client to cancel the operation.
		"""
        self.emit('exit')

    def signal_login_dialog_response(self, dialog, response, glade_dialog):
        server_version_info = None
        title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'

        if response == Gtk.ResponseType.CANCEL or response == Gtk.ResponseType.DELETE_EVENT:
            dialog.destroy()
            self.emit('exit')
            return True
        glade_dialog.objects_save_to_config()
        server = utilities.server_parse(self.config['server'], 22)
        username = self.config['server_username']
        password = self.config['server_password']
        if server[0] == 'localhost' or (utilities.is_valid_ip_address(
                server[0]) and ipaddress.ip_address(server[0]).is_loopback):
            local_port = self.config['server_remote_port']
            self.logger.info("connecting to local king-phisher instance")
        else:
            local_port = self._create_ssh_forwarder(server, username, password)
        if not local_port:
            return

        self.rpc = KingPhisherRPCClient(
            ('localhost', local_port),
            username=username,
            password=password,
            use_ssl=self.config.get('server_use_ssl'))
        if self.config.get('rpc.serializer'):
            try:
                self.rpc.set_serializer(self.config['rpc.serializer'])
            except ValueError as error:
                self.logger.error(
                    "failed to set the rpc serializer, error: '{0}'".format(
                        error.message))

        connection_failed = True
        try:
            assert self.rpc('client/initialize')
            server_version_info = self.rpc('version')
            assert server_version_info != None
        except AdvancedHTTPServerRPCError as err:
            if err.status == 401:
                self.logger.warning(
                    'failed to authenticate to the remote king phisher service'
                )
                gui_utilities.show_dialog_error(
                    title_rpc_error, self,
                    'The server responded that the credentials are invalid.')
            else:
                self.logger.warning(
                    'failed to connect to the remote rpc server with http status: '
                    + str(err.status))
                gui_utilities.show_dialog_error(
                    title_rpc_error, self,
                    'The server responded with HTTP status: ' +
                    str(err.status))
        except socket.error as error:
            gui_utilities.show_dialog_exc_socket_error(error, self)
        except Exception as error:
            self.logger.warning('failed to connect to the remote rpc service')
            gui_utilities.show_dialog_error(
                title_rpc_error, self,
                'Ensure that the King Phisher Server is currently running.')
        else:
            connection_failed = False
        finally:
            if connection_failed:
                self.rpc = None
                self.server_disconnect()
                return

        server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
        if isinstance(server_rpc_api_version, int):
            # compatibility with pre-0.2.0 version
            server_rpc_api_version = (server_rpc_api_version, 0)
        self.logger.info(
            "successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})"
            .format(server_version_info['version'], server_rpc_api_version[0],
                    server_rpc_api_version[1]))
        self.server_local_port = local_port

        error_text = None
        if server_rpc_api_version[0] < version.rpc_api_version.major or (
                server_rpc_api_version[0] == version.rpc_api_version.major
                and server_rpc_api_version[1] < version.rpc_api_version.minor):
            error_text = 'The server is running an old and incompatible version.'
            error_text += '\nPlease update the remote server installation.'
        elif server_rpc_api_version[0] > version.rpc_api_version.major:
            error_text = 'The client is running an old and incompatible version.'
            error_text += '\nPlease update the local client installation.'
        if error_text:
            gui_utilities.show_dialog_error(
                'The RPC API Versions Are Incompatible', self, error_text)
            self.server_disconnect()
            return
        dialog.destroy()
        self.emit('server-connected')
        return

    def server_disconnect(self):
        """Clean up the SSH TCP connections and disconnect from the server."""
        if self._ssh_forwarder:
            self._ssh_forwarder.stop()
            self._ssh_forwarder = None
            self.logger.info('stopped ssh port forwarding')
        return

    def load_server_config(self):
        """Load the necessary values from the server's configuration."""
        self.config['server_config'] = self.rpc('config/get', [
            'server.require_id', 'server.secret_id', 'server.tracking_image',
            'server.web_root'
        ])
        return

    def rename_campaign(self):
        campaign = self.rpc.remote_table_row('campaigns',
                                             self.config['campaign_id'])
        prompt = dialogs.TextEntryDialog.build_prompt(
            self.config, self, 'Rename Campaign',
            'Enter the new campaign name:', campaign.name)
        response = prompt.interact()
        if response == None or response == campaign.name:
            return
        self.rpc('campaigns/set', self.config['campaign_id'], ('name', ),
                 (response, ))
        gui_utilities.show_dialog_info(
            'Campaign Name Updated', self,
            'The campaign name was successfully changed')

    def delete_campaign(self):
        """
		Delete the campaign on the server. A confirmation dialog will be
		displayed before the operation is performed. If the campaign is
		deleted and a new campaign is not selected with
		:py:meth:`.show_campaign_selection`, the client will quit.
		"""
        if not gui_utilities.show_dialog_yes_no(
                'Delete This Campaign?', self,
                'This action is irreversible, all campaign data will be lost.'
        ):
            return
        self.rpc('campaign/delete', self.config['campaign_id'])
        if not self.show_campaign_selection():
            gui_utilities.show_dialog_error('Now Exiting', self,
                                            'A campaign must be selected.')
            self.client_quit()

    def edit_preferences(self):
        """
		Display a
		:py:class:`.dialogs.configuration.ConfigurationDialog`
		instance and saves the configuration to disk if cancel is not selected.
		"""
        dialog = dialogs.ConfigurationDialog(self.config, self)
        if dialog.interact() != Gtk.ResponseType.CANCEL:
            app = self.get_property('application')
            app.save_config()

    def export_campaign_xml(self):
        """Export the current campaign to an XML data file."""
        dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data',
                                                  self)
        file_name = self.config['campaign_name'] + '.xml'
        response = dialog.run_quick_save(file_name)
        dialog.destroy()
        if not response:
            return
        destination_file = response['target_path']
        export.campaign_to_xml(self.rpc, self.config['campaign_id'],
                               destination_file)

    def show_campaign_graph(self, graph_name):
        """
		Create a new :py:class:`.CampaignGraph` instance and make it into
		a window. *graph_name* must be the name of a valid, exported
		graph provider.

		:param str graph_name: The name of the graph to make a window of.
		"""
        cls = graphs.get_graph(graph_name)
        graph_inst = cls(self.config, self)
        graph_inst.load_graph()
        window = graph_inst.make_window()
        window.show()

    def show_campaign_selection(self):
        """
		Display the campaign selection dialog in a new
		:py:class:`.CampaignSelectionDialog` instance.

		:return: The status of the dialog.
		:rtype: bool
		"""
        dialog = dialogs.CampaignSelectionDialog(self.config, self)
        return dialog.interact() == Gtk.ResponseType.APPLY

    def start_sftp_client(self):
        """
		Start the client's preferred sftp client application.
		"""
        if not self.config['sftp_client']:
            gui_utilities.show_dialog_warning(
                'Invalid SFTP Configuration', self,
                'An SFTP client is not configured')
            return False
        command = str(self.config['sftp_client'])
        sftp_bin = shlex.split(command)[0]
        if not utilities.which(sftp_bin):
            self.logger.warning('could not locate the sftp binary: ' +
                                sftp_bin)
            gui_utilities.show_dialog_warning(
                'Invalid SFTP Configuration', self,
                "Could not find the SFTP binary '{0}'".format(sftp_bin))
            return False
        try:
            command = command.format(
                server=self.config['server'],
                username=self.config['server_username'],
                web_root=self.config['server_config']['server.web_root'])
        except KeyError:
            pass
        self.logger.debug("starting sftp client command: {0}".format(command))
        utilities.start_process(command, wait=False)
        return

    def stop_remote_service(self):
        """
		Stop the remote King Phisher server. This will request that the
		server stop processing new requests and exit. This will display
		a confirmation dialog before performing the operation. If the
		remote service is stopped, the client will quit.
		"""
        if not gui_utilities.show_dialog_yes_no(
                'Stop The Remote King Phisher Service?', self,
                'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'
        ):
            return
        self.rpc('shutdown')
        self.logger.info('the remote king phisher service has been stopped')
        gui_utilities.show_dialog_error(
            'Now Exiting', self, 'The remote service has been stopped.')
        self.client_quit()
        return
Exemple #20
0
class KingPhisherClient(_Gtk_ApplicationWindow):
	"""
	This is the top level King Phisher client object. It contains the
	custom GObject signals, keeps all the GUI references, and manages
	the RPC client object. This is also the parent window for most
	GTK objects.

	:GObject Signals: :ref:`gobject-signals-kingphisher-client-label`
	"""
	__gsignals__ = {
		'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
		'exit': (GObject.SIGNAL_RUN_LAST, None, ()),
		'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()),
		'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ())
	}
	def __init__(self, config, application):
		"""
		:param dict config: The main King Phisher client configuration.
		:param application: The application instance to which this window belongs.
		:type application: :py:class:`.KingPhisherClientApplication`
		"""
		assert isinstance(application, Gtk.Application)
		super(KingPhisherClient, self).__init__(application=application)
		self.application = application
		self.logger = logging.getLogger('KingPhisher.Client.MainWindow')
		self.config = config
		"""The main King Phisher client configuration."""
		self._ssh_forwarder = None
		self.set_property('title', 'King Phisher')
		vbox = Gtk.Box()
		vbox.set_property('orientation', Gtk.Orientation.VERTICAL)
		vbox.show()
		self.add(vbox)
		default_icon_file = find.find_data_file('king-phisher-icon.svg')
		if default_icon_file:
			icon_pixbuf = GdkPixbuf.Pixbuf.new_from_file(default_icon_file)
			self.set_default_icon(icon_pixbuf)
		action_group = Gtk.ActionGroup(name="client_window_actions")
		self._add_menu_actions(action_group)
		uimanager = self._create_ui_manager()
		self._add_menu_optional_actions(action_group, uimanager)
		self.add_accel_group(uimanager.get_accel_group())
		uimanager.insert_action_group(action_group)
		self.uimanager = uimanager
		menubar = uimanager.get_widget("/MenuBar")
		vbox.pack_start(menubar, False, False, 0)

		# create notebook and tabs
		self.notebook = Gtk.Notebook()
		"""The primary :py:class:`Gtk.Notebook` that holds the top level taps of the client GUI."""
		self.notebook.connect('switch-page', self.signal_notebook_switch_page)
		self.notebook.set_scrollable(True)
		vbox.pack_start(self.notebook, True, True, 0)

		self.tabs = {}
		current_page = self.notebook.get_current_page()
		self.last_page_id = current_page

		mailer_tab = MailSenderTab(self.config, self, self.application)
		self.tabs['mailer'] = mailer_tab
		self.notebook.insert_page(mailer_tab.box, mailer_tab.label, current_page + 1)
		self.notebook.set_current_page(current_page + 1)

		campaign_tab = CampaignViewTab(self.config, self, self.application)
		campaign_tab.box.show()
		self.tabs['campaign'] = campaign_tab
		self.notebook.insert_page(campaign_tab.box, campaign_tab.label, current_page + 2)

		self.set_size_request(800, 600)
		self.connect('delete-event', self.signal_delete_event)
		self.notebook.show()
		self.show()
		self.rpc = None # needs to be initialized last
		"""The :py:class:`.KingPhisherRPCClient` instance."""

		login_dialog = dialogs.LoginDialog(self.config, self)
		login_dialog.dialog.connect('response', self.signal_login_dialog_response, login_dialog)
		login_dialog.dialog.show()

	def _add_menu_actions(self, action_group):
		# File Menu Actions
		action = Gtk.Action(name='FileMenu', label='File', tooltip=None, stock_id=None)
		action_group.add_action(action)

		action = Gtk.Action(name='FileOpenCampaign', label='_Open Campaign', tooltip='Open a Campaign', stock_id=Gtk.STOCK_NEW)
		action.connect('activate', lambda x: self.show_campaign_selection())
		action_group.add_action_with_accel(action, '<control>O')

		action = Gtk.Action(name='FileImportMenu', label='Import', tooltip=None, stock_id=None)
		action_group.add_action(action)

		action = Gtk.Action(name='FileImportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None)
		action.connect('activate', lambda x: self.tabs['mailer'].import_message_data())
		action_group.add_action(action)

		action = Gtk.Action(name='FileExportMenu', label='Export', tooltip=None, stock_id=None)
		action_group.add_action(action)

		action = Gtk.Action(name='FileExportCampaignXML', label='Campaign XML', tooltip='Campaign XML', stock_id=None)
		action.connect('activate', lambda x: self.export_campaign_xml())
		action_group.add_action(action)

		action = Gtk.Action(name='FileExportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None)
		action.connect('activate', lambda x: self.tabs['mailer'].export_message_data())
		action_group.add_action(action)

		action = Gtk.Action(name='FileQuit', label=None, tooltip=None, stock_id=Gtk.STOCK_QUIT)
		action.connect('activate', lambda x: self.emit('exit-confirm'))
		action_group.add_action_with_accel(action, '<control>Q')

		# Edit Menu Actions
		action = Gtk.Action(name='EditMenu', label='Edit', tooltip=None, stock_id=None)
		action_group.add_action(action)

		action = Gtk.Action(name='EditPreferences', label='Preferences', tooltip='Edit Preferences', stock_id=Gtk.STOCK_EDIT)
		action.connect('activate', lambda x: self.edit_preferences())
		action_group.add_action(action)

		action = Gtk.Action(name='EditDeleteCampaign', label='Delete Campaign', tooltip='Delete Campaign', stock_id=None)
		action.connect('activate', lambda x: self.delete_campaign())
		action_group.add_action(action)

		action = Gtk.Action(name='EditRenameCampaign', label='Rename Campaign', tooltip='Rename Campaign', stock_id=None)
		action.connect('activate', lambda x: self.rename_campaign())
		action_group.add_action(action)

		action = Gtk.Action(name='EditStopService', label='Stop Service', tooltip='Stop The Remote King-Phisher Service', stock_id=None)
		action.connect('activate', lambda x: self.stop_remote_service())
		action_group.add_action(action)

		# Tools Menu Action
		action = Gtk.Action(name='ToolsMenu', label='Tools', tooltip=None, stock_id=None)
		action_group.add_action(action)

		action = Gtk.Action(name='ToolsRPCTerminal', label='RPC Terminal', tooltip='RPC Terminal', stock_id=None)
		action.connect('activate', lambda x: tools.KingPhisherClientRPCTerminal(self.config, self, self.get_property('application')))
		action_group.add_action_with_accel(action, '<control>F1')

		action = Gtk.Action(name='ToolsCloneWebPage', label='Clone Web Page', tooltip='Clone A Web Page', stock_id=None)
		action.connect('activate', lambda x: dialogs.ClonePageDialog(self.config, self).interact())
		action_group.add_action(action)

		# Help Menu Actions
		action = Gtk.Action(name='HelpMenu', label='Help', tooltip=None, stock_id=None)
		action_group.add_action(action)

		action = Gtk.Action(name='HelpAbout', label='About', tooltip='About', stock_id=None)
		action.connect('activate', lambda x: dialogs.AboutDialog(self.config, self).interact())
		action_group.add_action(action)

		action = Gtk.Action(name='HelpWiki', label='Wiki', tooltip='Wiki', stock_id=None)
		action.connect('activate', lambda x: utilities.open_uri('https://github.com/securestate/king-phisher/wiki'))
		action_group.add_action(action)

	def _add_menu_optional_actions(self, action_group, uimanager):
		if sys.platform.startswith('linux'):
			action = Gtk.Action(name='ToolsSFTPClient', label='SFTP Client', tooltip='SFTP Client', stock_id=None)
			action.connect('activate', lambda x: self.start_sftp_client())
			action_group.add_action_with_accel(action, '<control>F2')
			merge_id = uimanager.new_merge_id()
			uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsSFTPClient', 'ToolsSFTPClient', Gtk.UIManagerItemType.MENUITEM, False)

		if graphs.has_matplotlib:
			action = Gtk.Action(name='ToolsGraphMenu', label='Create Graph', tooltip='Create A Graph', stock_id=None)
			action_group.add_action(action)

			for graph_name in graphs.get_graphs():
				action_name = 'ToolsGraph' + graph_name
				graph = graphs.get_graph(graph_name)
				action = Gtk.Action(name=action_name, label=graph.name_human, tooltip=graph.name_human, stock_id=None)
				action.connect('activate', self.signal_activate_popup_menu_create_graph, graph_name)
				action_group.add_action(action)

			merge_id = uimanager.new_merge_id()
			uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu', 'ToolsGraphMenu', Gtk.UIManagerItemType.MENU, False)
			for graph_name in sorted(graphs.get_graphs(), key=lambda gn: graphs.get_graph(gn).name_human):
				action_name = 'ToolsGraph' + graph_name
				uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu', action_name, action_name, Gtk.UIManagerItemType.MENUITEM, False)

	def _create_ssh_forwarder(self, server, username, password):
		"""
		Create and set the :py:attr:`~.KingPhisherClient._ssh_forwarder`
		attribute.

		:param tuple server: The server information as a host and port tuple.
		:param str username: The username to authenticate to the SSH server with.
		:param str password: The password to authenticate to the SSH server with.
		:rtype: int
		:return: The local port that is forwarded to the remote server or None if the connection failed.
		"""
		title_ssh_error = 'Failed To Connect To The SSH Service'
		server_remote_port = self.config['server_remote_port']
		local_port = random.randint(2000, 6000)

		try:
			self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key'])
			self._ssh_forwarder.start()
			time.sleep(0.5)
			self.logger.info('started ssh port forwarding')
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			gui_utilities.show_dialog_error(title_ssh_error, self, 'The server responded that the credentials are invalid.')
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, self, title=title_ssh_error)
		except Exception:
			self.logger.warning('failed to connect to the remote ssh server')
			gui_utilities.show_dialog_error(title_ssh_error, self, 'An unknown error occurred.')
		else:
			return local_port
		self.server_disconnect()
		return

	def _create_ui_manager(self):
		uimanager = Gtk.UIManager()
		with open(find.find_data_file('ui_info/client_window.xml')) as ui_info_file:
			ui_data = ui_info_file.read()
		uimanager.add_ui_from_string(ui_data)
		return uimanager

	def signal_activate_popup_menu_create_graph(self, _, graph_name):
		return self.show_campaign_graph(graph_name)

	def signal_notebook_switch_page(self, notebook, current_page, index):
		#previous_page = notebook.get_nth_page(self.last_page_id)
		self.last_page_id = index
		mailer_tab = self.tabs.get('mailer')
		campaign_tab = self.tabs.get('campaign')

		notebook = None
		if mailer_tab and current_page == mailer_tab.box:
			notebook = mailer_tab.notebook
		elif campaign_tab and current_page == campaign_tab.box:
			notebook = campaign_tab.notebook

		if notebook:
			index = notebook.get_current_page()
			notebook.emit('switch-page', notebook.get_nth_page(index), index)

	def signal_delete_event(self, x, y):
		self.emit('exit-confirm')
		return True

	def do_campaign_set(self, campaign_id):
		self.rpc.cache_clear()
		self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id']))

	def do_exit(self):
		self.hide()
		gui_utilities.gtk_widget_destroy_children(self)
		gui_utilities.gtk_sync()
		self.server_disconnect()
		self.destroy()
		return

	def do_exit_confirm(self):
		self.emit('exit')

	def do_server_connected(self):
		self.load_server_config()
		campaign_id = self.config.get('campaign_id')
		if campaign_id == None:
			if not self.show_campaign_selection():
				self.logger.debug('no campaign selected, disconnecting and exiting')
				self.emit('exit')
				return True
		campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True)
		if campaign_info == None:
			if not self.show_campaign_selection():
				self.logger.debug('no campaign selected, disconnecting and exiting')
				self.emit('exit')
				return True
			campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True)
		self.config['campaign_name'] = campaign_info.name
		self.emit('campaign-set', self.config['campaign_id'])
		return

	def client_quit(self):
		"""
		Unconditionally quit the client and perform any necessary clean up
		operations. The exit-confirm signal will not be sent so there will not
		be any opportunities for the client to cancel the operation.
		"""
		self.emit('exit')

	def signal_login_dialog_response(self, dialog, response, glade_dialog):
		server_version_info = None
		title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'

		if response == Gtk.ResponseType.CANCEL or response == Gtk.ResponseType.DELETE_EVENT:
			dialog.destroy()
			self.emit('exit')
			return True
		glade_dialog.objects_save_to_config()
		server = utilities.server_parse(self.config['server'], 22)
		username = self.config['server_username']
		password = self.config['server_password']
		if server[0] == 'localhost' or (utilities.is_valid_ip_address(server[0]) and ipaddress.ip_address(server[0]).is_loopback):
			local_port = self.config['server_remote_port']
			self.logger.info("connecting to local king-phisher instance")
		else:
			local_port = self._create_ssh_forwarder(server, username, password)
		if not local_port:
			return

		self.rpc = KingPhisherRPCClient(('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl'))
		if self.config.get('rpc.serializer'):
			try:
				self.rpc.set_serializer(self.config['rpc.serializer'])
			except ValueError as error:
				self.logger.error("failed to set the rpc serializer, error: '{0}'".format(error.message))

		connection_failed = True
		try:
			assert self.rpc('client/initialize')
			server_version_info = self.rpc('version')
			assert server_version_info != None
		except AdvancedHTTPServerRPCError as err:
			if err.status == 401:
				self.logger.warning('failed to authenticate to the remote king phisher service')
				gui_utilities.show_dialog_error(title_rpc_error, self, 'The server responded that the credentials are invalid.')
			else:
				self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status))
				gui_utilities.show_dialog_error(title_rpc_error, self, 'The server responded with HTTP status: ' + str(err.status))
		except socket.error as error:
			gui_utilities.show_dialog_exc_socket_error(error, self)
		except Exception as error:
			self.logger.warning('failed to connect to the remote rpc service')
			gui_utilities.show_dialog_error(title_rpc_error, self, 'Ensure that the King Phisher Server is currently running.')
		else:
			connection_failed = False
		finally:
			if connection_failed:
				self.rpc = None
				self.server_disconnect()
				return

		server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
		if isinstance(server_rpc_api_version, int):
			# compatibility with pre-0.2.0 version
			server_rpc_api_version = (server_rpc_api_version, 0)
		self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})".format(server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1]))
		self.server_local_port = local_port

		error_text = None
		if server_rpc_api_version[0] < version.rpc_api_version.major or (server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor):
			error_text = 'The server is running an old and incompatible version.'
			error_text += '\nPlease update the remote server installation.'
		elif server_rpc_api_version[0] > version.rpc_api_version.major:
			error_text = 'The client is running an old and incompatible version.'
			error_text += '\nPlease update the local client installation.'
		if error_text:
			gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', self, error_text)
			self.server_disconnect()
			return
		dialog.destroy()
		self.emit('server-connected')
		return

	def server_disconnect(self):
		"""Clean up the SSH TCP connections and disconnect from the server."""
		if self._ssh_forwarder:
			self._ssh_forwarder.stop()
			self._ssh_forwarder = None
			self.logger.info('stopped ssh port forwarding')
		return

	def load_server_config(self):
		"""Load the necessary values from the server's configuration."""
		self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root'])
		return

	def rename_campaign(self):
		campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id'])
		prompt = dialogs.TextEntryDialog.build_prompt(self.config, self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name)
		response = prompt.interact()
		if response == None or response == campaign.name:
			return
		self.rpc('campaigns/set', self.config['campaign_id'], ('name',), (response,))
		gui_utilities.show_dialog_info('Campaign Name Updated', self, 'The campaign name was successfully changed')

	def delete_campaign(self):
		"""
		Delete the campaign on the server. A confirmation dialog will be
		displayed before the operation is performed. If the campaign is
		deleted and a new campaign is not selected with
		:py:meth:`.show_campaign_selection`, the client will quit.
		"""
		if not gui_utilities.show_dialog_yes_no('Delete This Campaign?', self, 'This action is irreversible, all campaign data will be lost.'):
			return
		self.rpc('campaign/delete', self.config['campaign_id'])
		if not self.show_campaign_selection():
			gui_utilities.show_dialog_error('Now Exiting', self, 'A campaign must be selected.')
			self.client_quit()

	def edit_preferences(self):
		"""
		Display a
		:py:class:`.dialogs.configuration.ConfigurationDialog`
		instance and saves the configuration to disk if cancel is not selected.
		"""
		dialog = dialogs.ConfigurationDialog(self.config, self)
		if dialog.interact() != Gtk.ResponseType.CANCEL:
			app = self.get_property('application')
			app.save_config()

	def export_campaign_xml(self):
		"""Export the current campaign to an XML data file."""
		dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data', self)
		file_name = self.config['campaign_name'] + '.xml'
		response = dialog.run_quick_save(file_name)
		dialog.destroy()
		if not response:
			return
		destination_file = response['target_path']
		export.campaign_to_xml(self.rpc, self.config['campaign_id'], destination_file)

	def show_campaign_graph(self, graph_name):
		"""
		Create a new :py:class:`.CampaignGraph` instance and make it into
		a window. *graph_name* must be the name of a valid, exported
		graph provider.

		:param str graph_name: The name of the graph to make a window of.
		"""
		cls = graphs.get_graph(graph_name)
		graph_inst = cls(self.config, self)
		graph_inst.load_graph()
		window = graph_inst.make_window()
		window.show()

	def show_campaign_selection(self):
		"""
		Display the campaign selection dialog in a new
		:py:class:`.CampaignSelectionDialog` instance.

		:return: The status of the dialog.
		:rtype: bool
		"""
		dialog = dialogs.CampaignSelectionDialog(self.config, self)
		return dialog.interact() == Gtk.ResponseType.APPLY

	def start_sftp_client(self):
		"""
		Start the client's preferred sftp client application.
		"""
		if not self.config['sftp_client']:
			gui_utilities.show_dialog_warning('Invalid SFTP Configuration', self, 'An SFTP client is not configured')
			return False
		command = str(self.config['sftp_client'])
		sftp_bin = shlex.split(command)[0]
		if not utilities.which(sftp_bin):
			self.logger.warning('could not locate the sftp binary: ' + sftp_bin)
			gui_utilities.show_dialog_warning('Invalid SFTP Configuration', self, "Could not find the SFTP binary '{0}'".format(sftp_bin))
			return False
		try:
			command = command.format(
				server=self.config['server'],
				username=self.config['server_username'],
				web_root=self.config['server_config']['server.web_root']
			)
		except KeyError:
			pass
		self.logger.debug("starting sftp client command: {0}".format(command))
		utilities.start_process(command, wait=False)
		return

	def stop_remote_service(self):
		"""
		Stop the remote King Phisher server. This will request that the
		server stop processing new requests and exit. This will display
		a confirmation dialog before performing the operation. If the
		remote service is stopped, the client will quit.
		"""
		if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', self, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'):
			return
		self.rpc('shutdown')
		self.logger.info('the remote king phisher service has been stopped')
		gui_utilities.show_dialog_error('Now Exiting', self, 'The remote service has been stopped.')
		self.client_quit()
		return
Exemple #21
0
class KingPhisherClient(_Gtk_Window):
    """
	This is the top level King Phisher client object. It contains the
	custom GObject signals, keeps all the GUI references, and manages
	the RPC client object. This is also the parent window for most
	GTK objects.

	:GObject Signals: :ref:`gobject-signals-kingphisher-client-label`
	"""
    __gsignals__ = {
        'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str, )),
        'exit': (GObject.SIGNAL_RUN_LAST, None, ()),
        'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ())
    }

    def __init__(self, config_file=None):
        """
		:param str config_file: The path to the configuration file to load.
		"""
        super(KingPhisherClient, self).__init__()
        self.logger = logging.getLogger('KingPhisher.Client')
        # print version information for debugging purposes
        self.logger.debug("gi.repository GLib version: {0}".format('.'.join(
            map(str, GLib.glib_version))))
        self.logger.debug("gi.repository GObject version: {0}".format('.'.join(
            map(str, GObject.pygobject_version))))
        self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format(
            Gtk.get_major_version(), Gtk.get_minor_version(),
            Gtk.get_micro_version()))
        if tools.has_vte:
            self.logger.debug("gi.repository VTE version: {0}".format(
                tools.Vte._version))
        if graphs.has_matplotlib:
            self.logger.debug("matplotlib version: {0}".format(
                graphs.matplotlib.__version__))
        self.config_file = (config_file or CONFIG_FILE_PATH)
        """The file containing the King Phisher client configuration."""
        self.ssh_forwarder = None
        """The :py:class:`.SSHTCPForwarder` instance used for tunneling traffic."""
        self.config = None
        """The main King Phisher client configuration."""
        try:
            self.load_config(load_defaults=True)
        except Exception:
            self.logger.critical('failed to load the client configuration')
            raise
        self.set_property('title', 'King Phisher')
        vbox = Gtk.Box()
        vbox.set_property('orientation', Gtk.Orientation.VERTICAL)
        vbox.show()
        self.add(vbox)
        default_icon_file = find.find_data_file('king-phisher-icon.svg')
        if default_icon_file:
            icon_pixbuf = GdkPixbuf.Pixbuf.new_from_file(default_icon_file)
            self.set_default_icon(icon_pixbuf)

        action_group = Gtk.ActionGroup(name="client_window_actions")
        self._add_menu_actions(action_group)
        uimanager = self._create_ui_manager()
        self._add_menu_optional_actions(action_group, uimanager)
        self.add_accel_group(uimanager.get_accel_group())
        uimanager.insert_action_group(action_group)
        self.uimanager = uimanager
        menubar = uimanager.get_widget("/MenuBar")
        vbox.pack_start(menubar, False, False, 0)

        # create notebook and tabs
        self.notebook = Gtk.Notebook()
        """The primary :py:class:`Gtk.Notebook` that holds the top level taps of the client GUI."""
        self.notebook.connect('switch-page', self.signal_notebook_switch_page)
        self.notebook.set_scrollable(True)
        vbox.pack_start(self.notebook, True, True, 0)

        self.tabs = {}
        current_page = self.notebook.get_current_page()
        self.last_page_id = current_page

        mailer_tab = MailSenderTab(self.config, self)
        self.tabs['mailer'] = mailer_tab
        self.notebook.insert_page(mailer_tab.box, mailer_tab.label,
                                  current_page + 1)
        self.notebook.set_current_page(current_page + 1)

        campaign_tab = CampaignViewTab(self.config, self)
        campaign_tab.box.show()
        self.tabs['campaign'] = campaign_tab
        self.notebook.insert_page(campaign_tab.box, campaign_tab.label,
                                  current_page + 2)

        self.set_size_request(800, 600)
        self.connect('delete-event', self.signal_delete_event)
        self.notebook.show()
        self.show()
        self.rpc = None  # needs to be initialized last
        """The :py:class:`.KingPhisherRPCClient` instance."""

    def _add_menu_actions(self, action_group):
        # File Menu Actions
        action = Gtk.Action(name='FileMenu',
                            label='File',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='FileOpenCampaign',
                            label='_Open Campaign',
                            tooltip='Open a Campaign',
                            stock_id=Gtk.STOCK_NEW)
        action.connect('activate', lambda x: self.show_campaign_selection())
        action_group.add_action_with_accel(action, '<control>O')

        action = Gtk.Action(name='FileImportMenu',
                            label='Import',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='FileImportMessageConfiguration',
                            label='Message Configuration',
                            tooltip='Message Configuration',
                            stock_id=None)
        action.connect('activate',
                       lambda x: self.tabs['mailer'].import_message_data())
        action_group.add_action(action)

        action = Gtk.Action(name='FileExportMenu',
                            label='Export',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='FileExportCampaignXML',
                            label='Campaign XML',
                            tooltip='Campaign XML',
                            stock_id=None)
        action.connect('activate', lambda x: self.export_campaign_xml())
        action_group.add_action(action)

        action = Gtk.Action(name='FileExportMessageConfiguration',
                            label='Message Configuration',
                            tooltip='Message Configuration',
                            stock_id=None)
        action.connect('activate',
                       lambda x: self.tabs['mailer'].export_message_data())
        action_group.add_action(action)

        action = Gtk.Action(name='FileQuit',
                            label=None,
                            tooltip=None,
                            stock_id=Gtk.STOCK_QUIT)
        action.connect('activate', lambda x: self.emit('exit-confirm'))
        action_group.add_action_with_accel(action, '<control>Q')

        # Edit Menu Actions
        action = Gtk.Action(name='EditMenu',
                            label='Edit',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='EditPreferences',
                            label='Preferences',
                            tooltip='Edit Preferences',
                            stock_id=Gtk.STOCK_EDIT)
        action.connect('activate', lambda x: self.edit_preferences())
        action_group.add_action(action)

        action = Gtk.Action(name='EditDeleteCampaign',
                            label='Delete Campaign',
                            tooltip='Delete Campaign',
                            stock_id=None)
        action.connect('activate', lambda x: self.delete_campaign())
        action_group.add_action(action)

        action = Gtk.Action(name='EditStopService',
                            label='Stop Service',
                            tooltip='Stop The Remote King-Phisher Service',
                            stock_id=None)
        action.connect('activate', lambda x: self.stop_remote_service())
        action_group.add_action(action)

        # Tools Menu Action
        action = Gtk.Action(name='ToolsMenu',
                            label='Tools',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='ToolsRPCTerminal',
                            label='RPC Terminal',
                            tooltip='RPC Terminal',
                            stock_id=None)
        action.connect(
            'activate',
            lambda x: tools.KingPhisherClientRPCTerminal(self.config, self))
        action_group.add_action(action)

        # Help Menu Actions
        action = Gtk.Action(name='HelpMenu',
                            label='Help',
                            tooltip=None,
                            stock_id=None)
        action_group.add_action(action)

        action = Gtk.Action(name='HelpAbout',
                            label='About',
                            tooltip='About',
                            stock_id=None)
        action.connect('activate', lambda x: self.show_about_dialog())
        action_group.add_action(action)

        action = Gtk.Action(name='HelpWiki',
                            label='Wiki',
                            tooltip='Wiki',
                            stock_id=None)
        action.connect(
            'activate', lambda x: utilities.open_uri(
                'https://github.com/securestate/king-phisher/wiki'))
        action_group.add_action(action)

    def _add_menu_optional_actions(self, action_group, uimanager):
        if graphs.has_matplotlib:
            action = Gtk.Action(name='ToolsGraphMenu',
                                label='Create Graph',
                                tooltip=None,
                                stock_id=None)
            action_group.add_action(action)

            for graph_name in graphs.get_graphs():
                action_name = 'ToolsGraph' + graph_name
                action = Gtk.Action(name=action_name,
                                    label=graph_name,
                                    tooltip=graph_name,
                                    stock_id=None)
                action.connect('activate',
                               lambda _: self.show_campaign_graph(graph_name))
                action_group.add_action(action)

            merge_id = uimanager.new_merge_id()
            uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu',
                             'ToolsGraphMenu', Gtk.UIManagerItemType.MENU,
                             False)
            for graph_name in graphs.get_graphs():
                action_name = 'ToolsGraph' + graph_name
                uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu',
                                 action_name, action_name,
                                 Gtk.UIManagerItemType.MENUITEM, False)

        if sys.platform.startswith('linux'):
            action = Gtk.Action(name='ToolsSFTPClient',
                                label='SFTP Client',
                                tooltip='SFTP Client',
                                stock_id=None)
            action.connect('activate', lambda x: self.start_sftp_client())
            action_group.add_action(action)
            merge_id = uimanager.new_merge_id()
            uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsSFTPClient',
                             'ToolsSFTPClient', Gtk.UIManagerItemType.MENUITEM,
                             False)

    def _create_ui_manager(self):
        uimanager = Gtk.UIManager()
        with open(find.find_data_file(
                'ui_info/client_window.xml')) as ui_info_file:
            ui_data = ui_info_file.read()
        uimanager.add_ui_from_string(ui_data)
        return uimanager

    def signal_notebook_switch_page(self, notebook, current_page, index):
        #previous_page = notebook.get_nth_page(self.last_page_id)
        self.last_page_id = index
        mailer_tab = self.tabs.get('mailer')
        campaign_tab = self.tabs.get('campaign')

        notebook = None
        if mailer_tab and current_page == mailer_tab.box:
            notebook = mailer_tab.notebook
        elif campaign_tab and current_page == campaign_tab.box:
            notebook = campaign_tab.notebook

        if notebook:
            index = notebook.get_current_page()
            notebook.emit('switch-page', notebook.get_nth_page(index), index)

    def signal_delete_event(self, x, y):
        self.emit('exit-confirm')
        return True

    def do_campaign_set(self, campaign_id):
        self.rpc.cache_clear()
        self.logger.info("campaign set to {0} (id: {1})".format(
            self.config['campaign_name'], self.config['campaign_id']))

    def do_exit(self):
        self.hide()
        gui_utilities.gtk_widget_destroy_children(self)
        gui_utilities.gtk_sync()
        self.server_disconnect()
        self.save_config()
        self.destroy()
        Gtk.main_quit()
        return

    def do_exit_confirm(self):
        self.emit('exit')

    def init_connection(self):
        """
		Initialize a connection to the King Phisher server. This will
		connect to the server and load necessary information from it.

		:return: Whether or not the connection attempt was successful.
		:rtype: bool
		"""
        if not self.server_connect():
            return False
        self.load_server_config()
        campaign_id = self.config.get('campaign_id')
        if campaign_id == None:
            if not self.show_campaign_selection():
                self.server_disconnect()
                return False
        campaign_info = self.rpc.remote_table_row('campaigns',
                                                  self.config['campaign_id'],
                                                  cache=True)
        if campaign_info == None:
            if not self.show_campaign_selection():
                self.server_disconnect()
                return False
            campaign_info = self.rpc.remote_table_row(
                'campaigns',
                self.config['campaign_id'],
                cache=True,
                refresh=True)
        self.config['campaign_name'] = campaign_info['name']
        self.emit('campaign-set', self.config['campaign_id'])
        return True

    def client_quit(self):
        """
		Unconditionally quit the client and perform any necessary clean up
		operations. The exit-confirm signal will not be sent so there will not
		be any opportunities for the client to cancel the operation.
		"""
        self.emit('exit')

    def server_connect(self):
        """
		Perform the connection setup as part of the server connection
		initialization process. This will display a GUI window requesting
		the connection information. An :py:class:`.SSHTCPForwarder` instance
		is created and configured for tunneling traffic to the King Phisher
		server. This also verifies that the RPC API version running on
		the server is compatible with the client.

		:return: Whether or not the connection attempt was successful.
		:rtype: bool
		"""
        server_version_info = None
        title_ssh_error = 'Failed To Connect To The SSH Service'
        title_rpc_error = 'Failed To Connect To The King Phisher RPC Service'
        while True:
            if self.ssh_forwarder:
                self.ssh_forwarder.stop()
                self.ssh_forwarder = None
                self.logger.info('stopped ssh port forwarding')
            login_dialog = dialogs.KingPhisherClientLoginDialog(
                self.config, self)
            login_dialog.objects_load_from_config()
            response = login_dialog.interact()
            if response == Gtk.ResponseType.CANCEL:
                return False
            server = utilities.server_parse(self.config['server'], 22)
            username = self.config['server_username']
            password = self.config['server_password']
            server_remote_port = self.config['server_remote_port']
            local_port = random.randint(2000, 6000)
            try:
                self.ssh_forwarder = SSHTCPForwarder(
                    server,
                    username,
                    password,
                    local_port, ('127.0.0.1', server_remote_port),
                    preferred_private_key=self.config['ssh_preferred_key'])
                self.ssh_forwarder.start()
                time.sleep(0.5)
                self.logger.info('started ssh port forwarding')
            except paramiko.AuthenticationException:
                self.logger.warning(
                    'failed to authenticate to the remote ssh server')
                gui_utilities.show_dialog_error(
                    title_ssh_error, self,
                    'The server responded that the credentials are invalid')
                continue
            except socket.error as error:
                error_number, error_message = error.args
                if error_number == 111:
                    gui_utilities.show_dialog_error(
                        title_ssh_error, self,
                        'The server refused the connection')
                else:
                    gui_utilities.show_dialog_error(
                        title_ssh_error, self,
                        "Socket error #{0} ({1})".format(
                            (error_number or 'NOT-SET'), error_message))
                continue
            except Exception:
                self.logger.warning(
                    'failed to connect to the remote ssh server')
                gui_utilities.show_dialog_error(title_ssh_error, self)
                continue
            self.rpc = KingPhisherRPCClient(
                ('localhost', local_port),
                username=username,
                password=password,
                use_ssl=self.config.get('server_use_ssl'))
            if self.config.get('rpc.serializer'):
                try:
                    self.rpc.set_serializer(self.config['rpc.serializer'])
                except ValueError as error:
                    self.logger.error(
                        "failed to set the rpc serializer, error: '{0}'".
                        format(error.message))
            try:
                assert (self.rpc('client/initialize'))
                server_version_info = self.rpc('version')
            except AdvancedHTTPServerRPCError as err:
                if err.status == 401:
                    self.logger.warning(
                        'failed to authenticate to the remote king phisher service'
                    )
                    gui_utilities.show_dialog_error(
                        title_rpc_error, self,
                        'The server responded that the credentials are invalid'
                    )
                else:
                    self.logger.warning(
                        'failed to connect to the remote rpc server with http status: '
                        + str(err.status))
                    gui_utilities.show_dialog_error(
                        title_rpc_error, self,
                        'The server responded with HTTP status: ' +
                        str(err.status))
                continue
            except:
                self.logger.warning(
                    'failed to connect to the remote rpc service')
                gui_utilities.show_dialog_error(
                    title_rpc_error, self,
                    'Ensure that the King Phisher Server is currently running')
                continue
            break
        assert (server_version_info != None)
        server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
        self.logger.info(
            "successfully connected to the king phisher server (version: {0} rpc api version: {1})"
            .format(server_version_info['version'], server_rpc_api_version))
        self.server_local_port = local_port
        if server_rpc_api_version != version.rpc_api_version:
            if version.rpc_api_version < server_rpc_api_version:
                secondary_text = 'The local client is not up to date with the server version.'
            else:
                secondary_text = 'The remote server is not up to date with the client version.'
            secondary_text += '\nPlease ensure that both the client and server are fully up to date.'
            gui_utilities.show_dialog_error(
                'The RPC API Versions Are Incompatible', self, secondary_text)
            self.server_disconnect()
            return False
        return True

    def server_disconnect(self):
        """Clean up the SSH TCP connections and disconnect from the server."""
        if self.ssh_forwarder:
            self.ssh_forwarder.stop()
            self.ssh_forwarder = None
            self.logger.info('stopped ssh port forwarding')
        return

    def load_config(self, load_defaults=False):
        """
		Load the client configuration from disk and set the
		:py:attr:`~.KingPhisherClient.config` attribute.

		:param bool load_defaults: Load missing options from the template configuration file.
		"""
        self.logger.info('loading the config from disk')
        config_file = os.path.expanduser(self.config_file)
        client_template = find.find_data_file('client_config.json')
        if not os.path.isfile(config_file):
            shutil.copy(client_template, config_file)
        with open(config_file, 'r') as tmp_file:
            self.config = json.load(tmp_file)
        if load_defaults:
            with open(client_template, 'r') as tmp_file:
                client_template = json.load(tmp_file)
            for key, value in client_template.items():
                if not key in self.config:
                    self.config[key] = value

    def load_server_config(self):
        """Load the necessary values from the server's configuration."""
        self.config['server_config'] = self.rpc(
            'config/get',
            ['server.require_id', 'server.secret_id', 'server.tracking_image'])
        return

    def save_config(self):
        """Write the client configuration to disk."""
        self.logger.info('writing the client configuration to disk')
        config = copy.copy(self.config)
        for key in self.config.keys():
            if 'password' in key or key == 'server_config':
                del config[key]
        config_file = os.path.expanduser(self.config_file)
        config_file_h = open(config_file, 'wb')
        json.dump(config, config_file_h, sort_keys=True, indent=4)

    def delete_campaign(self):
        """
		Delete the campaign on the server. A confirmation dialog will be
		displayed before the operation is performed. If the campaign is
		deleted and a new campaign is not selected with
		:py:meth:`.show_campaign_selection`, the client will quit.
		"""
        if not gui_utilities.show_dialog_yes_no(
                'Delete This Campaign?', self,
                'This action is irreversible, all campaign data will be lost.'
        ):
            return
        self.rpc('campaign/delete', self.config['campaign_id'])
        if not self.show_campaign_selection():
            gui_utilities.show_dialog_error('A Campaign Must Be Selected',
                                            self, 'Now exiting')
            self.client_quit()

    def edit_preferences(self):
        """
		Display a
		:py:class:`.dialogs.configuration.KingPhisherClientConfigurationDialog`
		instance and saves the configuration to disk if cancel is not selected.
		"""
        dialog = dialogs.KingPhisherClientConfigurationDialog(
            self.config, self)
        if dialog.interact() != Gtk.ResponseType.CANCEL:
            self.save_config()

    def export_campaign_xml(self):
        """Export the current campaign to an XML data file."""
        dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data',
                                                  self)
        file_name = self.config['campaign_name'] + '.xml'
        response = dialog.run_quick_save(file_name)
        dialog.destroy()
        if not response:
            return
        destination_file = response['target_path']
        export.campaign_to_xml(self.rpc, self.config['campaign_id'],
                               destination_file)

    def show_about_dialog(self):
        """
		Display the about dialog showing details about the programs version,
		license etc.
		"""
        license_text = None
        if os.path.splitext(__file__)[1] == '.py':
            source_file_h = open(__file__, 'r')
            source_code = []
            source_code.append(source_file_h.readline())
            while source_code[-1].startswith('#'):
                source_code.append(source_file_h.readline())
            source_code = source_code[5:-1]
            source_code = map(lambda x: x.strip('# '), source_code)
            license_text = ''.join(source_code)
        logo_pixbuf = None
        logo_file_path = find.find_data_file('king-phisher-icon.svg')
        if logo_file_path:
            logo_pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
                logo_file_path, 128, 128)
        about_dialog = Gtk.AboutDialog()
        about_dialog.set_transient_for(self)
        about_dialog_properties = {
            'authors': ['Spencer McIntyre', 'Jeff McCutchan', 'Brandan Geise'],
            'comments': 'Phishing Campaign Toolkit',
            'copyright': 'Copyright (c) 2013-2015, SecureState LLC',
            'license-type': Gtk.License.BSD,
            'program-name': 'King Phisher',
            'version': version.version,
            'website': 'https://github.com/securestate/king-phisher',
            'website-label': 'GitHub Home Page',
            'wrap-license': False,
        }
        if license_text:
            about_dialog_properties['license'] = license_text
        if logo_pixbuf:
            about_dialog_properties['logo'] = logo_pixbuf
        for property_name, property_value in about_dialog_properties.items():
            about_dialog.set_property(property_name, property_value)
        about_dialog.connect('activate-link',
                             lambda _, url: utilities.open_uri(url))
        about_dialog.show_all()
        about_dialog.run()
        about_dialog.destroy()

    def show_campaign_graph(self, graph_name):
        """
		Create a new :py:class:`.CampaignGraph` instance and make it into
		a window. *graph_name* must be the name of a valid, exported
		graph provider.

		:param str graph_name: The name of the graph to make a window of.
		"""
        Klass = graphs.get_graph(graph_name)
        graph_inst = Klass(self.config, self)
        graph_inst.load_graph()
        window = graph_inst.make_window()
        window.show_all()

    def show_campaign_selection(self):
        """
		Display the campaign selection dialog in a new
		:py:class:`.KingPhisherClientCampaignSelectionDialog` instance.

		:return: The status of the dialog.
		:rtype: bool
		"""
        dialog = dialogs.KingPhisherClientCampaignSelectionDialog(
            self.config, self)
        return dialog.interact() != Gtk.ResponseType.CANCEL

    def start_sftp_client(self):
        """
		Start the client's preferred sftp client application.
		"""
        if not self.config['sftp_client']:
            gui_utilities.show_dialog_warning(
                'Invalid SFTP Configuration', self,
                'An SFTP client is not configured')
            return False
        command = str(self.config['sftp_client'])
        sftp_bin = shlex.split(command)[0]
        if not utilities.which(sftp_bin):
            self.logger.warning('could not locate the sftp binary: ' +
                                sftp_bin)
            gui_utilities.show_dialog_warning(
                'Invalid SFTP Configuration', self,
                "Could not find the SFTP binary '{0}'".format(sftp_bin))
            return False
        try:
            command = command.format(username=self.config['server_username'],
                                     server=self.config['server'])
        except KeyError:
            pass
        self.logger.debug("starting sftp client command: {0}".format(command))
        utilities.start_process(command, wait=False)
        return

    def stop_remote_service(self):
        """
		Stop the remote King Phisher server. This will request that the
		server stop processing new requests and exit. This will display
		a confirmation dialog before performing the operation. If the
		remote service is stopped, the client will quit.
		"""
        if not gui_utilities.show_dialog_yes_no(
                'Stop The Remote King Phisher Service?', self,
                'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'
        ):
            return
        self.rpc('shutdown')
        self.logger.info('the remote king phisher service has been stopped')
        gui_utilities.show_dialog_error('The Remote Service Has Been Stopped',
                                        self, 'Now exiting')
        self.client_quit()
        return
Exemple #22
0
class MailSenderThread(threading.Thread):
	"""
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
	def __init__(self, application, target_file, rpc, tab=None):
		"""
		:param application: The GTK application that the thread is associated with.
		:type application: :py:class:`.KingPhisherClientApplication`
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
		super(MailSenderThread, self).__init__()
		self.daemon = True
		self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__)
		self.application = application
		self.config = self.application.config
		self.target_file = target_file
		"""The name of the target file in CSV format."""
		self.tab = tab
		"""The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
		self.rpc = rpc
		self._ssh_forwarder = None
		self.smtp_connection = None
		"""The :py:class:`smtplib.SMTP` connection instance."""
		self.smtp_server = parse_server(self.config['smtp_server'], 25)
		self.running = threading.Event()
		"""A :py:class:`threading.Event` object indicating if emails are being sent."""
		self.paused = threading.Event()
		"""A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
		self.should_stop = threading.Event()
		self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))

	def tab_notify_sent(self, emails_done, emails_total):
		"""
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total))

	def tab_notify_status(self, message):
		"""
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
		self.logger.info(message.lower())
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_status, message + '\n')

	def tab_notify_stopped(self):
		"""
		Notify the tab that the message sending operation has stopped.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_stopped)

	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port forwarding
		with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		server = parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = parse_server(self.config['smtp_server'], 25)
		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				remote_server,
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
		except paramiko.SSHException as error:
			self.logger.warning("failed with ssh exception '{0}'".format(error.message))
		except Exception:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
		else:
			self.smtp_server = self._ssh_forwarder.local_server
			return ConnectionErrorReason.SUCCESS
		return ConnectionErrorReason.ERROR_UNKNOWN

	def server_smtp_connect(self):
		"""
		Connect and optionally authenticate to the configured SMTP server.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		if self.config.get('smtp_ssl_enable', False):
			SmtpClass = smtplib.SMTP_SSL
		else:
			SmtpClass = smtplib.SMTP
		try:
			self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15)
			self.smtp_connection.ehlo()
		except socket.error:
			self.logger.warning('received a socket.error while connecting to the SMTP server')
			return ConnectionErrorReason.ERROR_CONNECTION
		except smtplib.SMTPException:
			self.logger.warning('received an SMTPException while connecting to the SMTP server', exc_info=True)
			return ConnectionErrorReason.ERROR_UNKNOWN

		username = self.config.get('smtp_username', '')
		if username:
			password = self.config.get('smtp_password', '')
			try:
				self.smtp_connection.login(username, password)
			except smtplib.SMTPException as error:
				self.logger.warning('received an {0} while authenticating to the SMTP server'.format(error.__class__.__name__))
				self.smtp_connection.quit()
				return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
		return ConnectionErrorReason.SUCCESS

	def server_smtp_disconnect(self):
		"""Clean up and close the connection to the remote SMTP server."""
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
			self.tab_notify_status('Disconnected from the SMTP server')

	def server_smtp_reconnect(self):
		"""
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
		while self.server_smtp_connect() != ConnectionErrorReason.SUCCESS:
			self.tab_notify_status('Failed to reconnect to the SMTP server')
			if not self.process_pause(True):
				return False
		return True

	def count_messages(self):
		"""
		Count the number of targets that will be sent messages.

		:return: The number of targets that will be sent messages.
		:rtype: int
		"""
		return sum(1 for _ in self.iterate_targets())

	def iterate_targets(self):
		target_type = self.config['mailer.target_type']
		mailer_tab = self.application.main_tabs['mailer']
		if target_type == 'single':
			target_name = self.config['mailer.target_name'].split(' ')
			while len(target_name) < 2:
				target_name.append('')
			target = MessageTarget(
				first_name=target_name[0].strip(),
				last_name=target_name[1].strip(),
				email_address=self.config['mailer.target_email_address'],
				department=None,
				uid=make_uid()
			)
			mailer_tab.emit('send-target', target)
			yield target
		elif target_type == 'file':
			target_file_h = open(self.target_file, 'rU')
			csv_reader = csv.DictReader(target_file_h, ('first_name', 'last_name', 'email_address', 'department'))
			for line_no, raw_target in enumerate(csv_reader, 1):
				department = raw_target['department']
				if department is not None:
					department = department.strip()
					if department == '':
						department = None
				email_address = raw_target['email_address'] or ''
				email_address = email_address.strip()
				target = MessageTarget(
					first_name=raw_target['first_name'].strip(),
					last_name=raw_target['last_name'].strip(),
					email_address=email_address,
					department=department,
					uid=make_uid(),
					line=line_no
				)
				mailer_tab.emit('send-target', target)
				if not target.email_address:
					self.logger.warning("skipping line {0} in target csv file due to missing email address".format(line_no))
					continue
				if not utilities.is_valid_email_address(target.email_address):
					self.logger.warning("skipping line {0} in target csv file due to invalid email address: {1}".format(line_no, email_address))
					continue
				yield target
			target_file_h.close()
		else:
			self.logger.error("the configured target type '{0}' is unsupported".format(target_type))

	def run(self):
		self.running.set()
		self.should_stop.clear()
		self.paused.clear()

		try:
			self._prepare_env()
			emails_done = self._send_messages()
		except Exception:
			self.logger.error('an error occurred while sending messages', exc_info=True)
			self.tab_notify_status('An error occurred while sending messages.')
		else:
			self.tab_notify_status("Finished sending, successfully sent {0:,} messages.".format(emails_done))

		self.server_smtp_disconnect()
		if self._ssh_forwarder:
			self._ssh_forwarder.stop()
			self._ssh_forwarder = None
			self.tab_notify_status('Disconnected from the SSH server')
		self.tab_notify_stopped()
		return

	def process_pause(self, set_pause=False):
		"""
		Pause sending emails if a pause request has been set.

		:param bool set_pause: Whether to request a pause before processing it.
		:return: Whether or not the sending operation was cancelled during the pause.
		:rtype: bool
		"""
		if set_pause:
			if isinstance(self.tab, gui_utilities.GladeGObject):
				gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True))
			else:
				self.pause()
		if self.paused.is_set():
			self.tab_notify_status('Paused sending emails, waiting to resume')
			self.running.wait()
			self.paused.clear()
			if self.should_stop.is_set():
				self.tab_notify_status('Sending emails cancelled')
				return False
			self.tab_notify_status('Resuming sending emails')
			self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		return True

	def create_calendar_invite(self, target, attachments):
		"""
		Create a MIME calendar invite to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`Attachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
		top_msg = TopMIMEMultipart('mixed', self.config, target)

		related_msg = mime.multipart.MIMEMultipart('related')
		top_msg.attach(related_msg)

		alt_msg = mime.multipart.MIMEMultipart('alternative')
		related_msg.attach(alt_msg)

		part = mime.base.MIMEBase('text', 'plain', charset='utf-8')
		part.set_payload('This calendar invite requires an HTML enabled viewer.\r\n\r\n')
		encoders.encode_base64(part)
		alt_msg.attach(part)

		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		formatted_msg = render_message_template(msg_template, self.config, target=target)
		part = mime.text.MIMEText(formatted_msg, 'html', 'utf-8')
		alt_msg.attach(part)

		start_time = get_invite_start_from_config(self.config)
		if self.config['mailer.calendar_invite_all_day']:
			duration = ics.DurationAllDay()
		else:
			duration = int(self.config['mailer.calendar_invite_duration']) * 60
		ical = ics.Calendar(
			self.config['mailer.source_email'],
			start_time,
			self.config.get('mailer.calendar_invite_summary'),
			duration=duration,
			location=self.config.get('mailer.calendar_invite_location')
		)
		ical.add_attendee(target.email_address, rsvp=self.config.get('mailer.calendar_request_rsvp', False))

		part = mime.base.MIMEBase('text', 'calendar', charset='utf-8', method='REQUEST')
		part.set_payload(ical.to_ical(encoding='utf-8'))
		encoders.encode_base64(part)
		alt_msg.attach(part)

		for attach in attachments.images:
			related_msg.attach(attach)

		for attach in attachments.files:
			top_msg.attach(attach)
		return top_msg

	def create_email(self, target, attachments):
		"""
		Create a MIME email to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`MessageAttachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
		msg = TopMIMEMultipart('related', self.config, target)
		importance = self.config.get('mailer.importance', 'Normal')
		if importance != 'Normal':
			msg['Importance'] = importance
		sensitivity = self.config.get('mailer.sensitivity', 'Normal')
		if sensitivity != 'Normal':
			msg['Sensitivity'] = sensitivity

		msg_alt = mime.multipart.MIMEMultipart('alternative')
		msg.attach(msg_alt)
		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		formatted_msg = render_message_template(msg_template, self.config, target=target)
		msg_body = mime.text.MIMEText(formatted_msg, 'html', 'utf-8')
		msg_alt.attach(msg_body)

		# process attachments
		for attach in attachments.files:
			msg.attach(attach)
		for attach in attachments.images:
			msg.attach(attach)
		return msg

	def get_mime_attachments(self):
		"""
		Return a :py:class:`.MessageAttachments` object containing both the images and
		raw files to be included in sent messages.

		:return: A namedtuple of both files and images in their MIME containers.
		:rtype: :py:class:`.MessageAttachments`
		"""
		files = []
		if self.config.get('mailer.attachment_file'):
			attachment = self.config['mailer.attachment_file']
			attachfile = mime.base.MIMEBase(*mimetypes.guess_type(attachment))
			attachfile.set_payload(open(attachment, 'rb').read())
			encoders.encode_base64(attachfile)
			attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment)))
			files.append(attachfile)

		images = []
		for attachment_file, attachment_name in template_environment.attachment_images.items():
			attachfile = mime.image.MIMEImage(open(attachment_file, 'rb').read())
			attachfile.add_header('Content-ID', "<{0}>".format(attachment_name))
			attachfile.add_header('Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name))
			images.append(attachfile)
		return MessageAttachments(tuple(files), tuple(images))

	def _prepare_env(self):
		with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h:
			msg_template = file_h.read()
		render_message_template(msg_template, self.config, analyze=True)
		template_environment.set_mode(template_environment.MODE_SEND)

	def _send_messages(self):
		emails_done = 0
		max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5)

		emails_total = "{0:,}".format(self.count_messages())
		sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(len(emails_total), emails_total)
		emails_total = int(emails_total.replace(',', ''))
		attachments = self.get_mime_attachments()
		self.logger.debug("loaded {0:,} MIME attachments".format(sum((len(attachments.files), len(attachments.images)))))

		for target in self.iterate_targets():
			iteration_time = time.time()
			if self.should_stop.is_set():
				self.tab_notify_status('Sending emails cancelled')
				break
			if not self.process_pause():
				break
			if emails_done > 0 and max_messages_per_connection > 0 and (emails_done % max_messages_per_connection):
				self.server_smtp_reconnect()

			emails_done += 1
			self.tab_notify_status(sending_line.format(emails_done, target.uid, target.email_address))
			msg = getattr(self, 'create_' + self.config['mailer.message_type'])(target, attachments)
			if not self._try_send_message(target.email_address, msg):
				break

			self.tab_notify_sent(emails_done, emails_total)
			campaign_id = self.config['campaign_id']
			self.rpc('campaign/message/new', campaign_id, target.uid, target.email_address, target.first_name, target.last_name, target.department)
			self.application.emit('message-sent', target.uid, target.email_address)

			if self.max_messages_per_minute:
				iteration_time = (time.time() - iteration_time)
				sleep_time = (60.0 / float(self.max_messages_per_minute)) - iteration_time
				while sleep_time > 0:
					sleep_chunk = min(sleep_time, 0.5)
					time.sleep(sleep_chunk)
					if self.should_stop.is_set():
						break
					sleep_time -= sleep_chunk
		return emails_done

	def _try_send_message(self, *args, **kwargs):
		message_sent = False
		while not message_sent:
			for _ in range(0, 3):
				try:
					self.send_message(*args, **kwargs)
					message_sent = True
					break
				except smtplib.SMTPException as error:
					self.tab_notify_status("Failed to send message (exception: {0})".format(error.__class__.__name__))
					self.logger.warning("failed to send message (exception: smtplib.{0})".format(error.__class__.__name__))
					time.sleep(1)
			if not message_sent:
				self.server_smtp_disconnect()
				if not self.process_pause(True):
					return False
				self.server_smtp_reconnect()
		return True

	def send_message(self, target_email, msg):
		"""
		Send an email using the connected SMTP server.

		:param str target_email: The email address to send the message to.
		:param msg: The formatted message to be sent.
		:type msg: :py:class:`.mime.multipart.MIMEMultipart`
		"""
		source_email = self.config['mailer.source_email_smtp']
		self.smtp_connection.sendmail(source_email, target_email, msg.as_string())

	def pause(self):
		"""
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is paused.
		"""
		self.running.clear()
		self.paused.set()

	def unpause(self):
		"""
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is no longer paused.
		"""
		self.running.set()

	def stop(self):
		"""
		Requests that the email sending operation stop. It can not be
		resumed from the same position. This function blocks until the
		stop request has been processed and the thread exits.
		"""
		self.should_stop.set()
		self.unpause()
		if self.is_alive():
			self.join()

	def missing_files(self):
		"""
		Return a list of all missing or unreadable files which are referenced by
		the message template.

		:return: The list of unusable files.
		:rtype: list
		"""
		missing = []
		attachment = self.config.get('mailer.attachment_file')
		if attachment and not os.access(attachment, os.R_OK):
			missing.append(attachment)
		msg_template = self.config['mailer.html_file']
		if not os.access(msg_template, os.R_OK):
			missing.append(msg_template)
			return missing
		self._prepare_env()
		for attachment in template_environment.attachment_images.keys():
			if not os.access(attachment, os.R_OK):
				missing.append(attachment)
		return missing
class MailSenderThread(threading.Thread):
	def __init__(self, config, target_file, tab, rpc):
		super(MailSenderThread, self).__init__()
		self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__)
		self.config = config
		self.target_file = target_file
		self.tab = tab
		self.rpc = rpc
		self.ssh_forwarder = None
		self.smtp_connection = None
		self.smtp_server = utilities.server_parse(self.config['smtp_server'], 25)
		self.running = threading.Event()
		self.paused = threading.Event()
		self.should_exit = threading.Event()
		self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))

	def server_ssh_connect(self):
		server = utilities.server_parse(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = utilities.server_parse(self.config['smtp_server'], 25)
		local_port = random.randint(2000, 6000)
		try:
			self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key'))
			self.ssh_forwarder.start()
			time.sleep(0.5)
		except:
			self.logger.warning('failed to connect to remote ssh server')
			return False
		self.smtp_server = ('localhost', local_port)
		return True

	def server_smtp_connect(self):
		if self.config.get('smtp_ssl_enable', False):
			SMTP_CLASS = smtplib.SMTP_SSL
		else:
			SMTP_CLASS = smtplib.SMTP
		try:
			self.smtp_connection = SMTP_CLASS(*self.smtp_server)
		except:
			return False
		return True

	def server_smtp_disconnect(self):
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
			GLib.idle_add(self.tab.notify_status, 'Disconnected From SMTP Server\n')

	def server_smtp_reconnect(self):
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
		while not self.server_smtp_connect():
			GLib.idle_add(self.tab.notify_status, 'Failed To Reconnect To The SMTP Server\n')
			if not self.process_pause(True):
				return False
		return True

	def count_emails(self, target_file):
		targets = 0
		target_file_h = open(target_file, 'r')
		csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address'])
		for target in csv_reader:
			targets += 1
		target_file_h.close()
		return targets

	def run(self):
		emails_done = 0
		emails_total = self.count_emails(self.target_file)
		max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5)
		self.running.set()
		self.should_exit.clear()
		self.paused.clear()

		target_file_h = open(self.target_file, 'r')
		csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address'])
		for target in csv_reader:
			iteration_time = time.time()
			if emails_done > 0 and (emails_done % max_messages_per_connection):
				self.server_smtp_reconnect()
			if self.should_exit.is_set():
				GLib.idle_add(self.tab.notify_status, 'Sending Emails Cancelled\n')
				break
			if not self.process_pause():
				break
			uid = make_uid()
			emails_done += 1
			GLib.idle_add(self.tab.notify_status, "Sending Email {0} of {1} To {2} With UID: {3}\n".format(emails_done, emails_total, target['email_address'], uid))
			msg = self.create_email(target['first_name'], target['last_name'], target['email_address'], uid)
			if not self.try_send_email(target['email_address'], msg):
				break
			GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total))
			campaign_id = self.config['campaign_id']
			company_name = self.config.get('mailer.company_name', '')
			self.rpc('campaign/message/new', campaign_id, uid, target['email_address'], company_name, target['first_name'], target['last_name'])
			if self.max_messages_per_minute:
				iteration_time = (time.time() - iteration_time)
				sleep_time = (60.0 / float(self.max_messages_per_minute)) - iteration_time
				if sleep_time > 0:
					time.sleep(sleep_time)
		target_file_h.close()
		GLib.idle_add(self.tab.notify_status, "Finished Sending Emails, Successfully Sent {0} Emails\n".format(emails_done))
		self.server_smtp_disconnect()
		if self.ssh_forwarder:
			self.ssh_forwarder.stop()
			self.ssh_forwarder = None
			GLib.idle_add(self.tab.notify_status, 'Disconnected From SSH Server\n')
		GLib.idle_add(self.tab.notify_stopped)
		return

	def process_pause(self, set_pause=False):
		if set_pause:
			gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True))
		if self.paused.is_set():
			GLib.idle_add(self.tab.notify_status, 'Paused Sending Emails, Waiting To Resume\n')
			self.running.wait()
			self.paused.clear()
			if self.should_exit.is_set():
				GLib.idle_add(self.tab.notify_status, 'Sending Emails Cancelled\n')
				return False
			GLib.idle_add(self.tab.notify_status, 'Resuming Sending Emails\n')
			self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		return True

	def create_email(self, first_name, last_name, target_email, uid):
		msg = MIMEMultipart()
		msg['Subject'] = self.config['mailer.subject']
		if self.config.get('mailer.reply_to_email'):
			msg.add_header('reply-to', self.config['mailer.reply_to_email'])
		if self.config.get('mailer.source_email_alias'):
			msg['From'] = "\"{0}\" <{1}>".format(self.config['mailer.source_email_alias'], self.config['mailer.source_email'])
		else:
			msg['From'] = self.config['mailer.source_email']
		msg['To'] = target_email
		importance = self.config.get('mailer.importance', 'Normal')
		if importance != 'Normal':
			msg['Importance'] = importance
		sensitivity = self.config.get('mailer.sensitivity', 'Normal')
		if sensitivity != 'Normal':
			msg['Sensitivity'] = sensitivity
		msg.preamble = 'This is a multi-part message in MIME format.'
		msg_alt = MIMEMultipart('alternative')
		msg.attach(msg_alt)
		msg_template = open(self.config['mailer.html_file'], 'r').read()
		formatted_msg = format_message(msg_template, self.config, first_name=first_name, last_name=last_name, uid=uid, target_email=target_email)
		msg_body = MIMEText(formatted_msg, "html")
		msg_alt.attach(msg_body)
		if self.config.get('mailer.attachment_file'):
			attachment = self.config['mailer.attachment_file']
			attachfile = MIMEBase(*mimetypes.guess_type(attachment))
			attachfile.set_payload(open(attachment, 'rb').read())
			Encoders.encode_base64(attachfile)
			attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment)))
			msg.attach(attachfile)
		return msg

	def try_send_email(self, *args, **kwargs):
		message_sent = False
		while not message_sent:
			for i in xrange(0, 3):
				try:
					self.send_email(*args, **kwargs)
					message_sent = True
					break
				except:
					GLib.idle_add(self.tab.notify_status, 'Failed To Send Message\n')
					time.sleep(1)
			if not message_sent:
				self.server_smtp_disconnect()
				if not self.process_pause(True):
					return False
				self.server_smtp_reconnect()
		return True

	def send_email(self, target_email, msg):
		source_email = self.config['mailer.source_email']
		self.smtp_connection.sendmail(source_email, target_email, msg.as_string())

	def pause(self):
		self.running.clear()
		self.paused.set()

	def unpause(self):
		self.running.set()

	def stop(self):
		self.should_exit.set()
		self.unpause()
		if self.is_alive():
			self.join()
Exemple #24
0
class MailSenderThread(threading.Thread):
    """
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
    def __init__(self, config, target_file, rpc, tab=None):
        """
		:param dict config: The King Phisher client configuration.
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
        super(MailSenderThread, self).__init__()
        self.daemon = True
        self.logger = logging.getLogger('KingPhisher.Client.' +
                                        self.__class__.__name__)
        self.config = config
        self.target_file = target_file
        """The name of the target file in CSV format."""
        self.tab = tab
        """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
        self.rpc = rpc
        self.ssh_forwarder = None
        """The :py:class:`.SSHTCPForwarder` instance for tunneling traffic to the SMTP server."""
        self.smtp_connection = None
        """The :py:class:`smtplib.SMTP` connection instance."""
        self.smtp_server = utilities.server_parse(self.config['smtp_server'],
                                                  25)
        self.running = threading.Event()
        """A :py:class:`threading.Event` object indicating if emails are being sent."""
        self.paused = threading.Event()
        """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
        self.should_exit = threading.Event()
        self.max_messages_per_minute = float(
            self.config.get('smtp_max_send_rate', 0.0))
        self._mime_attachments = None

    def tab_notify_sent(self, emails_done, emails_total):
        """
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
        if isinstance(self.tab, gui_utilities.UtilityGladeGObject):
            GLib.idle_add(lambda x: self.tab.notify_sent(*x),
                          (emails_done, emails_total))

    def tab_notify_status(self, message):
        """
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
        self.logger.info(message.lower())
        if isinstance(self.tab, gui_utilities.UtilityGladeGObject):
            GLib.idle_add(self.tab.notify_status, message + '\n')

    def tab_notify_stopped(self):
        """
		Notify the tab that the message sending operation has stopped.
		"""
        if isinstance(self.tab, gui_utilities.UtilityGladeGObject):
            GLib.idle_add(self.tab.notify_stopped)

    def server_ssh_connect(self):
        """
		Connect to the remote SMTP server over SSH and configure port
		forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP
		traffic.

		:return: The connection status.
		:rtype: bool
		"""
        server = utilities.server_parse(self.config['ssh_server'], 22)
        username = self.config['ssh_username']
        password = self.config['ssh_password']
        remote_server = utilities.server_parse(self.config['smtp_server'], 25)
        local_port = random.randint(2000, 6000)
        try:
            self.ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                local_port,
                remote_server,
                preferred_private_key=self.config.get('ssh_preferred_key'))
            self.ssh_forwarder.start()
            time.sleep(0.5)
        except:
            self.logger.warning('failed to connect to remote ssh server')
            return False
        self.smtp_server = ('localhost', local_port)
        return True

    def server_smtp_connect(self):
        """
		Connect to the configured SMTP server.

		:return: The connection status.
		:rtype: bool
		"""
        if self.config.get('smtp_ssl_enable', False):
            SMTP_CLASS = smtplib.SMTP_SSL
        else:
            SMTP_CLASS = smtplib.SMTP
        try:
            self.smtp_connection = SMTP_CLASS(*self.smtp_server)
        except:
            return False
        return True

    def server_smtp_disconnect(self):
        """Clean up and close the connection to the remote SMTP server."""
        if self.smtp_connection:
            try:
                self.smtp_connection.quit()
            except smtplib.SMTPServerDisconnected:
                pass
            self.smtp_connection = None
            self.tab_notify_status('Disconnected from the SMTP server')

    def server_smtp_reconnect(self):
        """
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
        if self.smtp_connection:
            try:
                self.smtp_connection.quit()
            except smtplib.SMTPServerDisconnected:
                pass
            self.smtp_connection = None
        while not self.server_smtp_connect():
            self.tab_notify_status('Failed to reconnect to the SMTP server')
            if not self.process_pause(True):
                return False
        return True

    def count_emails(self):
        """
		Count the emails contained in the target CSV file.

		:return: The number of targets in the file.
		:rtype: int
		"""
        targets = 0
        target_file_h = open(self.target_file, 'rU')
        csv_reader = csv.DictReader(
            target_file_h, ['first_name', 'last_name', 'email_address'])
        for target in csv_reader:
            if not utilities.is_valid_email_address(target['email_address']):
                continue
            targets += 1
        target_file_h.close()
        return targets

    def run(self):
        emails_done = 0
        emails_total = self.count_emails()
        max_messages_per_connection = self.config.get(
            'mailer.max_messages_per_connection', 5)
        self.running.set()
        self.should_exit.clear()
        self.paused.clear()
        self._prepare_env()

        self._mime_attachments = self._get_mime_attachments()
        self.logger.debug("loaded {0:,} MIME attachments".format(
            len(self._mime_attachments)))

        target_file_h = open(self.target_file, 'rU')
        csv_reader = csv.DictReader(
            target_file_h, ['first_name', 'last_name', 'email_address'])
        for target in csv_reader:
            if not utilities.is_valid_email_address(target['email_address']):
                self.logger.warning('skipping invalid email address: ' +
                                    target['email_address'])
                continue
            iteration_time = time.time()
            if self.should_exit.is_set():
                self.tab_notify_status('Sending emails cancelled')
                break
            if not self.process_pause():
                break
            if emails_done > 0 and (emails_done % max_messages_per_connection):
                self.server_smtp_reconnect()

            uid = make_uid()
            emails_done += 1
            self.tab_notify_status(
                "Sending email {0:,} of {1:,} to {2} with UID: {3}".format(
                    emails_done, emails_total, target['email_address'], uid))
            msg = self.create_email(target['first_name'], target['last_name'],
                                    target['email_address'], uid)
            if not self._try_send_email(target['email_address'], msg):
                break

            self.tab_notify_sent(emails_done, emails_total)
            campaign_id = self.config['campaign_id']
            company_name = self.config.get('mailer.company_name', '')
            self.rpc('campaign/message/new', campaign_id, uid,
                     target['email_address'], company_name,
                     target['first_name'], target['last_name'])

            if self.max_messages_per_minute:
                iteration_time = (time.time() - iteration_time)
                sleep_time = (60.0 / float(
                    self.max_messages_per_minute)) - iteration_time
                while sleep_time > 0:
                    sleep_chunk = min(sleep_time, 0.5)
                    time.sleep(sleep_chunk)
                    if self.should_exit.is_set():
                        break
                    sleep_time -= sleep_chunk

        target_file_h.close()
        self._mime_attachments = None

        self.tab_notify_status(
            "Finished sending emails, successfully sent {0:,} emails".format(
                emails_done))
        self.server_smtp_disconnect()
        if self.ssh_forwarder:
            self.ssh_forwarder.stop()
            self.ssh_forwarder = None
            self.tab_notify_status('Disconnected from the SSH server')
        self.tab_notify_stopped()
        return

    def process_pause(self, set_pause=False):
        """
		Pause sending emails if a pause request has been set.

		:param bool set_pause: Whether to request a pause before processing it.
		:return: Whether or not the sending operation was cancelled during the pause.
		:rtype: bool
		"""
        if set_pause:
            if isinstance(self.tab, gui_utilities.UtilityGladeGObject):
                gui_utilities.glib_idle_add_wait(
                    lambda: self.tab.pause_button.set_property('active', True))
            else:
                self.pause()
        if self.paused.is_set():
            self.tab_notify_status('Paused sending emails, waiting to resume')
            self.running.wait()
            self.paused.clear()
            if self.should_exit.is_set():
                self.tab_notify_status('Sending emails cancelled')
                return False
            self.tab_notify_status('Resuming sending emails')
            self.max_messages_per_minute = float(
                self.config.get('smtp_max_send_rate', 0.0))
        return True

    def create_email(self, first_name, last_name, target_email, uid):
        """
		Create a MIME email to be sent from a set of parameters.

		:param str first_name: The first name of the message's recipient.
		:param str last_name: The last name of the message's recipient.
		:param str target_email: The message's destination email address.
		:param str uid: The message's unique identifier.
		:return: The new MIME message.
		:rtype: :py:class:`email.MIMEMultipart.MIMEMultipart`
		"""
        msg = MIMEMultipart()
        msg['Subject'] = self.config['mailer.subject']
        if self.config.get('mailer.reply_to_email'):
            msg.add_header('reply-to', self.config['mailer.reply_to_email'])
        if self.config.get('mailer.source_email_alias'):
            msg['From'] = "\"{0}\" <{1}>".format(
                self.config['mailer.source_email_alias'],
                self.config['mailer.source_email'])
        else:
            msg['From'] = self.config['mailer.source_email']
        msg['To'] = target_email
        importance = self.config.get('mailer.importance', 'Normal')
        if importance != 'Normal':
            msg['Importance'] = importance
        sensitivity = self.config.get('mailer.sensitivity', 'Normal')
        if sensitivity != 'Normal':
            msg['Sensitivity'] = sensitivity
        msg.preamble = 'This is a multi-part message in MIME format.'
        msg_alt = MIMEMultipart('alternative')
        msg.attach(msg_alt)
        with open(self.config['mailer.html_file'], 'rb') as file_h:
            msg_template = file_h.read()
        msg_template = str(msg_template.decode('utf-8', 'ignore'))
        formatted_msg = format_message(msg_template,
                                       self.config,
                                       first_name=first_name,
                                       last_name=last_name,
                                       uid=uid,
                                       target_email=target_email)
        msg_body = MIMEText(formatted_msg, "html")
        msg_alt.attach(msg_body)

        # process attachments
        if isinstance(self._mime_attachments, (list, tuple)):
            attachfiles = self._mime_attachments
        else:
            attachfiles = self._get_mime_attachments()
        for attachfile in attachfiles:
            msg.attach(attachfile)
        return msg

    def _get_mime_attachments(self):
        attachments = []
        if self.config.get('mailer.attachment_file'):
            attachment = self.config['mailer.attachment_file']
            attachfile = MIMEBase(*mimetypes.guess_type(attachment))
            attachfile.set_payload(open(attachment, 'rb').read())
            encoders.encode_base64(attachfile)
            attachfile.add_header(
                'Content-Disposition', "attachment; filename=\"{0}\"".format(
                    os.path.basename(attachment)))
            attachments.append(attachfile)
        for attachment_file, attachment_name in template_environment.attachment_images.items(
        ):
            attachfile = MIMEImage(open(attachment_file, 'rb').read())
            attachfile.add_header('Content-ID',
                                  "<{0}>".format(attachment_name))
            attachfile.add_header(
                'Content-Disposition',
                "inline; filename=\"{0}\"".format(attachment_name))
            attachments.append(attachfile)
        return attachments

    def _prepare_env(self):
        msg_template = open(self.config['mailer.html_file'], 'r').read()
        template_environment.set_mode(template_environment.MODE_ANALYZE)
        format_message(msg_template, self.config, uid=make_uid())
        template_environment.set_mode(template_environment.MODE_SEND)

    def _try_send_email(self, *args, **kwargs):
        message_sent = False
        while not message_sent:
            for i in xrange(0, 3):
                try:
                    self.send_email(*args, **kwargs)
                    message_sent = True
                    break
                except:
                    self.tab_notify_status('Failed to send message')
                    time.sleep(1)
            if not message_sent:
                self.server_smtp_disconnect()
                if not self.process_pause(True):
                    return False
                self.server_smtp_reconnect()
        return True

    def send_email(self, target_email, msg):
        """
		Send an email using the connected SMTP server.

		:param str target_email: The email address to send the message to.
		:param msg: The formatted message to be sent.
		:type msg: :py:class:`email.MIMEMultipart.MIMEMultipart`
		"""
        source_email = self.config['mailer.source_email']
        self.smtp_connection.sendmail(source_email, target_email,
                                      msg.as_string())

    def pause(self):
        """
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is paused.
		"""
        self.running.clear()
        self.paused.set()

    def unpause(self):
        """
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is no longer paused.
		"""
        self.running.set()

    def stop(self):
        """
		Requests that the email sending operation stop. It can not be
		resumed from the same position. This function blocks until the
		stop request has been processed and the thread exits.
		"""
        self.should_exit.set()
        self.unpause()
        if self.is_alive():
            self.join()

    def missing_files(self):
        """
		Return a list of all missing or unreadable files which are referenced by
		the message template.

		:return: The list of unusable files.
		:rtype: list
		"""
        missing = []
        attachment = self.config.get('mailer.attachment_file')
        if attachment and not os.access(attachment, os.R_OK):
            missing.append(attachment)
        msg_template = self.config['mailer.html_file']
        if not os.access(msg_template, os.R_OK):
            missing.append(msg_template)
            return missing
        self._prepare_env()
        missing.extend(
            filter(lambda f: not os.access(f, os.R_OK),
                   template_environment.attachment_images.keys()))
        return missing
Exemple #25
0
class MailSenderThread(threading.Thread):
    """
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
    def __init__(self, application, target_file, rpc, tab=None):
        """
		:param application: The GTK application that the thread is associated with.
		:type application: :py:class:`.KingPhisherClientApplication`
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
        super(MailSenderThread, self).__init__()
        self.daemon = True
        self.logger = logging.getLogger('KingPhisher.Client.' +
                                        self.__class__.__name__)
        self.application = application
        self.config = self.application.config
        self.target_file = target_file
        """The name of the target file in CSV format."""
        self.tab = tab
        """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
        self.rpc = rpc
        self._ssh_forwarder = None
        self.smtp_connection = None
        """The :py:class:`smtplib.SMTP` connection instance."""
        self.smtp_server = smoke_zephyr.utilities.parse_server(
            self.config['smtp_server'], 25)
        self.running = threading.Event()
        """A :py:class:`threading.Event` object indicating if emails are being sent."""
        self.paused = threading.Event()
        """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
        self.should_stop = threading.Event()
        self.max_messages_per_minute = float(
            self.config.get('smtp_max_send_rate', 0.0))
        self.mail_options = []

    def tab_notify_sent(self, emails_done, emails_total):
        """
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
        if isinstance(self.tab, gui_utilities.GladeGObject):
            GLib.idle_add(lambda x: self.tab.notify_sent(*x),
                          (emails_done, emails_total))

    def tab_notify_status(self, message):
        """
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
        self.logger.info(message.lower())
        if isinstance(self.tab, gui_utilities.GladeGObject):
            GLib.idle_add(self.tab.notify_status, message + '\n')

    def tab_notify_stopped(self):
        """
		Notify the tab that the message sending operation has stopped.
		"""
        if isinstance(self.tab, gui_utilities.GladeGObject):
            GLib.idle_add(self.tab.notify_stopped)

    def server_ssh_connect(self):
        """
		Connect to the remote SMTP server over SSH and configure port forwarding
		with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
        server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'],
                                                     22)
        username = self.config['ssh_username']
        password = self.config['ssh_password']
        remote_server = smoke_zephyr.utilities.parse_server(
            self.config['smtp_server'], 25)
        try:
            self._ssh_forwarder = SSHTCPForwarder(
                server,
                username,
                password,
                remote_server,
                private_key=self.config.get('ssh_preferred_key'),
                missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(
                    self.application))
            self._ssh_forwarder.start()
        except errors.KingPhisherAbortError as error:
            self.logger.info("ssh connection aborted ({0})".format(
                error.message))
        except paramiko.AuthenticationException:
            self.logger.warning(
                'failed to authenticate to the remote ssh server')
            return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
        except paramiko.SSHException as error:
            self.logger.warning("failed with ssh exception '{0}'".format(
                error.message))
        except Exception:
            self.logger.warning('failed to connect to the remote ssh server',
                                exc_info=True)
        else:
            self.smtp_server = self._ssh_forwarder.local_server
            return ConnectionErrorReason.SUCCESS
        return ConnectionErrorReason.ERROR_UNKNOWN

    def server_smtp_connect(self):
        """
		Connect and optionally authenticate to the configured SMTP server.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
        if self.config.get('smtp_ssl_enable', False):
            SmtpClass = smtplib.SMTP_SSL
        else:
            SmtpClass = smtplib.SMTP
        self.logger.debug('opening a new connection to the SMTP server')
        try:
            self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15)
            self.smtp_connection.ehlo()
        except smtplib.SMTPException:
            self.logger.warning(
                'received an SMTPException while connecting to the SMTP server',
                exc_info=True)
            return ConnectionErrorReason.ERROR_UNKNOWN
        except socket.error:
            self.logger.warning(
                'received a socket.error while connecting to the SMTP server')
            return ConnectionErrorReason.ERROR_CONNECTION

        if not self.config.get(
                'smtp_ssl_enable',
                False) and 'starttls' in self.smtp_connection.esmtp_features:
            self.logger.debug(
                'target SMTP server supports the STARTTLS extension')
            try:
                self.smtp_connection.starttls()
                self.smtp_connection.ehlo()
            except smtplib.SMTPException:
                self.logger.warning(
                    'received an SMTPException while negotiating STARTTLS with the SMTP server',
                    exc_info=True)
                return ConnectionErrorReason.ERROR_UNKNOWN

        username = self.config.get('smtp_username', '')
        if username:
            password = self.config.get('smtp_password', '')
            try:
                self.smtp_connection.login(username, password)
            except smtplib.SMTPException as error:
                self.logger.warning(
                    'received an {0} while authenticating to the SMTP server'.
                    format(error.__class__.__name__))
                self.smtp_connection.quit()
                return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED

        if self.smtp_connection.has_extn('SMTPUTF8'):
            self.logger.debug(
                'target SMTP server supports the SMTPUTF8 extension')
            self.mail_options.append('SMTPUTF8')
        return ConnectionErrorReason.SUCCESS

    def server_smtp_disconnect(self):
        """Clean up and close the connection to the remote SMTP server."""
        if self.smtp_connection:
            self.logger.debug('closing the connection to the SMTP server')
            try:
                self.smtp_connection.quit()
            except smtplib.SMTPServerDisconnected:
                pass
            self.smtp_connection = None
            self.tab_notify_status('Disconnected from the SMTP server')

    def server_smtp_reconnect(self):
        """
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
        if self.smtp_connection:
            try:
                self.smtp_connection.quit()
            except smtplib.SMTPServerDisconnected:
                pass
            self.smtp_connection = None
        while self.server_smtp_connect() != ConnectionErrorReason.SUCCESS:
            self.tab_notify_status('Failed to reconnect to the SMTP server')
            if not self.process_pause(True):
                return False
        return True

    def count_messages(self):
        """
		Count the number of targets that will be sent messages.

		:return: The number of targets that will be sent messages.
		:rtype: int
		"""
        return sum(1 for _ in self.iterate_targets(counting=True))

    def iterate_targets(self, counting=False):
        target_type = self.config['mailer.target_type']
        if target_type == 'single':
            target_name = self.config['mailer.target_name'].split(' ')
            while len(target_name) < 2:
                target_name.append('')
            target = MessageTarget(
                first_name=target_name[0].strip(),
                last_name=target_name[1].strip(),
                email_address=self.config['mailer.target_email_address'].strip(
                ),
                department=None,
                uid=utilities.make_message_uid())
            yield target
        elif target_type == 'file':
            target_file_h = open(self.target_file, 'rU')
            csv_reader = csv.DictReader(
                target_file_h,
                ('first_name', 'last_name', 'email_address', 'department'))
            for line_no, raw_target in enumerate(csv_reader, 1):
                if its.py_v2:
                    # this will intentionally cause a UnicodeDecodeError to be raised as is the behaviour in python 3.x
                    # when csv.DictReader is initialized
                    raw_target = dict(
                        (k, (v if v is None else v.decode('utf-8')))
                        for k, v in raw_target.items())
                for required_field in ('first_name', 'last_name',
                                       'email_address'):
                    if raw_target[required_field] is None:
                        raw_target = None
                        if counting:
                            self.tab_notify_status(
                                "Target CSV line {0} skipped due to missing field: {1}"
                                .format(line_no,
                                        required_field.replace('_', ' ')))
                        break
                if raw_target is None:
                    continue
                department = raw_target['department']
                if department is not None:
                    department = department.strip()
                    if department == '':
                        department = None
                email_address = raw_target['email_address'] or ''
                email_address = email_address.strip()
                target = MessageTarget(
                    first_name=raw_target['first_name'].strip(),
                    last_name=raw_target['last_name'].strip(),
                    email_address=email_address,
                    department=department,
                    uid=utilities.make_message_uid(),
                    line=line_no)
                if not target.email_address:
                    self.logger.warning(
                        "skipping line {0} in target csv file due to missing email address"
                        .format(line_no))
                    continue
                if not utilities.is_valid_email_address(target.email_address):
                    self.logger.warning(
                        "skipping line {0} in target csv file due to invalid email address: {1}"
                        .format(line_no, email_address))
                    continue
                yield target
            target_file_h.close()
        else:
            self.logger.error(
                "the configured target type '{0}' is unsupported".format(
                    target_type))

    def run(self):
        self.logger.debug("mailer routine running in tid: 0x{0:x}".format(
            threading.current_thread().ident))
        self.running.set()
        self.should_stop.clear()
        self.paused.clear()

        try:
            self._prepare_env()
            emails_done = self._send_messages()
        except UnicodeDecodeError as error:
            self.logger.error(
                "a unicode error occurred, {0} at position: {1}-{2}".format(
                    error.reason, error.start, error.end))
            self.tab_notify_status(
                "A unicode error occurred, {0} at position: {1}-{2}".format(
                    error.reason, error.start, error.end))
        except Exception:
            self.logger.error('an error occurred while sending messages',
                              exc_info=True)
            self.tab_notify_status('An error occurred while sending messages.')
        else:
            self.tab_notify_status(
                "Finished sending, successfully sent {0:,} messages".format(
                    emails_done))

        self.server_smtp_disconnect()
        if self._ssh_forwarder:
            self._ssh_forwarder.stop()
            self._ssh_forwarder = None
            self.tab_notify_status('Disconnected from the SSH server')
        self.tab_notify_stopped()
        return

    def process_pause(self, set_pause=False):
        """
		Pause sending emails if a pause request has been set.

		:param bool set_pause: Whether to request a pause before processing it.
		:return: Whether or not the sending operation was cancelled during the pause.
		:rtype: bool
		"""
        if set_pause:
            if isinstance(self.tab, gui_utilities.GladeGObject):
                gui_utilities.glib_idle_add_wait(
                    lambda: self.tab.pause_button.set_property('active', True))
            else:
                self.pause()
        if self.paused.is_set():
            self.tab_notify_status('Paused sending emails, waiting to resume')
            self.running.wait()
            self.paused.clear()
            if self.should_stop.is_set():
                self.tab_notify_status('Sending emails cancelled')
                return False
            self.tab_notify_status('Resuming sending emails')
            self.max_messages_per_minute = float(
                self.config.get('smtp_max_send_rate', 0.0))
        return True

    def create_calendar_invite(self, target, attachments):
        """
		Create a MIME calendar invite to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`Attachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
        top_msg = TopMIMEMultipart('mixed', self.config, target)
        top_msg['To'] = target.email_address

        related_msg = mime.multipart.MIMEMultipart('related')
        top_msg.attach(related_msg)

        alt_msg = mime.multipart.MIMEMultipart('alternative')
        related_msg.attach(alt_msg)

        part = mime.base.MIMEBase('text', 'plain', charset='utf-8')
        part.set_payload(
            'This calendar invite requires an HTML enabled viewer.\r\n\r\n')
        encoders.encode_base64(part)
        alt_msg.attach(part)

        with codecs.open(self.config['mailer.html_file'],
                         'r',
                         encoding='utf-8') as file_h:
            msg_template = file_h.read()
        formatted_msg = render_message_template(msg_template,
                                                self.config,
                                                target=target)
        part = mime.text.MIMEText(formatted_msg, 'html', 'utf-8')
        alt_msg.attach(part)

        start_time = get_invite_start_from_config(self.config)
        if self.config['mailer.calendar_invite_all_day']:
            duration = ics.DurationAllDay()
        else:
            duration = int(self.config['mailer.calendar_invite_duration']) * 60
        ical = ics.Calendar(
            self.config['mailer.source_email'],
            start_time,
            self.config.get('mailer.calendar_invite_summary'),
            duration=duration,
            location=self.config.get('mailer.calendar_invite_location'))
        ical.add_attendee(target.email_address,
                          rsvp=self.config.get('mailer.calendar_request_rsvp',
                                               False))

        part = mime.base.MIMEBase('text',
                                  'calendar',
                                  charset='utf-8',
                                  method='REQUEST')
        part.set_payload(ical.to_ical(encoding='utf-8'))
        encoders.encode_base64(part)
        alt_msg.attach(part)

        for attach in attachments.images:
            related_msg.attach(attach)

        for attach in attachments.files:
            top_msg.attach(attach)
        return top_msg

    def create_email(self, target, attachments):
        """
		Create a MIME email to be sent from a set of parameters.

		:param target: The information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		:param str uid: The message's unique identifier.
		:param attachments: The attachments to add to the created message.
		:type attachments: :py:class:`MessageAttachments`
		:return: The new MIME message.
		:rtype: :py:class:`email.mime.multipart.MIMEMultipart`
		"""
        msg = TopMIMEMultipart('related', self.config, target)
        target_field = self.config.get('mailer.target_field', 'to').lower()
        for header in ('To', 'CC', 'BCC'):
            if header.lower() == target_field:
                msg[header] = target.email_address
                continue
            value = self.config.get('mailer.recipient_email_' + header.lower())
            if value:
                msg[header] = value

        importance = self.config.get('mailer.importance', 'Normal')
        if importance != 'Normal':
            msg['Importance'] = importance
        sensitivity = self.config.get('mailer.sensitivity', 'Normal')
        if sensitivity != 'Normal':
            msg['Sensitivity'] = sensitivity

        msg_alt = mime.multipart.MIMEMultipart('alternative')
        msg.attach(msg_alt)
        with codecs.open(self.config['mailer.html_file'],
                         'r',
                         encoding='utf-8') as file_h:
            msg_template = file_h.read()
        formatted_msg = render_message_template(msg_template,
                                                self.config,
                                                target=target)
        msg_body = mime.text.MIMEText(formatted_msg, 'html', 'utf-8')
        msg_alt.attach(msg_body)

        # process attachments
        for attach in attachments.files:
            msg.attach(attach)
        for attach in attachments.images:
            msg.attach(attach)
        return msg

    def get_mime_attachments(self):
        """
		Return a :py:class:`.MessageAttachments` object containing both the images and
		raw files to be included in sent messages.

		:return: A namedtuple of both files and images in their MIME containers.
		:rtype: :py:class:`.MessageAttachments`
		"""
        files = []
        # allow the attachment_file.post_processing to be attached instead of
        # attachment_file so attachment_file can be used as an input for
        # arbitrary operations to modify without over writing the original
        attachment_file = self.config.get(
            'mailer.attachment_file.post_processing')
        delete_attachment_file = False
        if attachment_file is not None:
            if not isinstance(attachment_file, str):
                raise TypeError(
                    'config option mailer.attachment_file.post_processing is not a readable file'
                )
            if not os.path.isfile(attachment_file) and os.access(
                    attachment_file, os.R_OK):
                raise ValueError(
                    'config option mailer.attachment_file.post_processing is not a readable file'
                )
            self.config['mailer.attachment_file.post_processing'] = None
            delete_attachment_file = True
        else:
            attachment_file = self.config.get('mailer.attachment_file')
        if attachment_file:
            attachfile = mime.base.MIMEBase(
                *mimetypes.guess_type(attachment_file))
            attachfile.set_payload(open(attachment_file, 'rb').read())
            encoders.encode_base64(attachfile)
            attachfile.add_header(
                'Content-Disposition', "attachment; filename=\"{0}\"".format(
                    os.path.basename(attachment_file)))
            files.append(attachfile)
            if delete_attachment_file and os.access(attachment_file, os.W_OK):
                os.remove(attachment_file)

        images = []
        for attachment_file, attachment_name in template_environment.attachment_images.items(
        ):
            attachfile = mime.image.MIMEImage(
                open(attachment_file, 'rb').read())
            attachfile.add_header('Content-ID',
                                  "<{0}>".format(attachment_name))
            attachfile.add_header(
                'Content-Disposition',
                "inline; filename=\"{0}\"".format(attachment_name))
            images.append(attachfile)
        return MessageAttachments(tuple(files), tuple(images))

    def _prepare_env(self):
        with codecs.open(self.config['mailer.html_file'],
                         'r',
                         encoding='utf-8') as file_h:
            msg_template = file_h.read()
        render_message_template(msg_template, self.config, analyze=True)
        template_environment.set_mode(template_environment.MODE_SEND)

    def _send_messages(self):
        emails_done = 0
        mailer_tab = self.application.main_tabs['mailer']
        max_messages_per_connection = self.config.get(
            'mailer.max_messages_per_connection', 5)

        emails_total = "{0:,}".format(self.count_messages())
        sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(
            len(emails_total), emails_total)
        emails_total = int(emails_total.replace(',', ''))

        for target in self.iterate_targets():
            iteration_time = time.time()
            if self.should_stop.is_set():
                self.tab_notify_status('Sending emails cancelled')
                break
            if not self.process_pause():
                break
            if emails_done > 0 and max_messages_per_connection > 0 and (
                    emails_done % max_messages_per_connection == 0):
                self.server_smtp_reconnect()

            emails_done += 1
            self.tab_notify_status(
                sending_line.format(emails_done, target.uid,
                                    target.email_address))
            mailer_tab.emit('send-target', target)
            attachments = self.get_mime_attachments()
            msg = getattr(self, 'create_' +
                          self.config['mailer.message_type'])(target,
                                                              attachments)
            mailer_tab.emit('send-message', target, msg)
            if not self._try_send_message(target.email_address, msg):
                break

            self.tab_notify_sent(emails_done, emails_total)
            campaign_id = self.config['campaign_id']
            self.rpc('campaign/message/new', campaign_id, target.uid,
                     target.email_address, target.first_name, target.last_name,
                     target.department)
            self.application.emit('message-sent', target.uid,
                                  target.email_address)

            if self.max_messages_per_minute:
                iteration_time = (time.time() - iteration_time)
                self._sleep((60.0 / float(self.max_messages_per_minute)) -
                            iteration_time)
        return emails_done

    def _sleep(self, duration):
        while duration > 0:
            sleep_chunk = min(duration, 0.5)
            time.sleep(sleep_chunk)
            if self.should_stop.is_set():
                break
            duration -= sleep_chunk
        return self.should_stop.is_set()

    def _try_send_message(self, *args, **kwargs):
        message_sent = False
        while not message_sent and not self.should_stop.is_set():
            for i in range(0, 3):
                try:
                    self.send_message(*args, **kwargs)
                    message_sent = True
                    break
                except smtplib.SMTPServerDisconnected:
                    self.logger.warning(
                        'failed to send message, the server has been disconnected'
                    )
                    self.tab_notify_status(
                        'Failed to send message, the server has been disconnected'
                    )
                    self.tab_notify_status(
                        'Sleeping for 5 seconds before attempting to reconnect'
                    )
                    if self._sleep(5):
                        break
                    self.smtp_connection = None
                    self.server_smtp_reconnect()
                except smtplib.SMTPException as error:
                    self.tab_notify_status(
                        "Failed to send message (exception: {0})".format(
                            error.__class__.__name__))
                    self.logger.warning(
                        "failed to send message (exception: smtplib.{0})".
                        format(error.__class__.__name__))
                    self._sleep((i + 1)**2)
            if not message_sent:
                self.server_smtp_disconnect()
                if not self.process_pause(True):
                    return False
                self.server_smtp_reconnect()
        return True

    def send_message(self, target_email, msg):
        """
		Send an email using the connected SMTP server.

		:param str target_email: The email address to send the message to.
		:param msg: The formatted message to be sent.
		:type msg: :py:class:`.mime.multipart.MIMEMultipart`
		"""
        source_email = self.config['mailer.source_email_smtp']
        self.smtp_connection.sendmail(source_email, target_email,
                                      msg.as_string(), self.mail_options)

    def pause(self):
        """
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is paused.
		"""
        self.running.clear()
        self.paused.set()

    def unpause(self):
        """
		Sets the :py:attr:`~.MailSenderThread.running` and
		:py:attr:`~.MailSenderThread.paused` flags correctly to indicate
		that the object is no longer paused.
		"""
        self.running.set()

    def stop(self):
        """
		Requests that the email sending operation stop. It can not be
		resumed from the same position. This function blocks until the
		stop request has been processed and the thread exits.
		"""
        self.should_stop.set()
        self.unpause()
        if self.is_alive():
            self.join()

    def missing_files(self):
        """
		Return a list of all missing or unreadable files which are referenced by
		the message template.

		:return: The list of unusable files.
		:rtype: list
		"""
        missing = []
        attachment = self.config.get('mailer.attachment_file')
        if attachment and not os.access(attachment, os.R_OK):
            missing.append(attachment)
        msg_template = self.config['mailer.html_file']
        if not os.access(msg_template, os.R_OK):
            missing.append(msg_template)
            return missing
        self._prepare_env()
        for attachment in template_environment.attachment_images.keys():
            if not os.access(attachment, os.R_OK):
                missing.append(attachment)
        return missing
class KingPhisherClient(Gtk.Window):
	__gsignals__ = {
		'campaign_set': (GObject.SIGNAL_RUN_FIRST, None, (str,))
	}
	def __init__(self, config_file=None):
		super(KingPhisherClient, self).__init__()
		self.logger = logging.getLogger('KingPhisher.Client')
		self.config_file = (config_file or CONFIG_FILE_PATH)
		self.load_config()
		self.set_property('title', 'King Phisher')
		vbox = Gtk.Box()
		vbox.set_property('orientation', Gtk.Orientation.VERTICAL)
		vbox.show()
		self.add(vbox)

		action_group = Gtk.ActionGroup("client_window_actions")
		self._add_menu_actions(action_group)
		uimanager = self._create_ui_manager()
		uimanager.insert_action_group(action_group)
		self.uimanager = uimanager
		menubar = uimanager.get_widget("/MenuBar")
		vbox.pack_start(menubar, False, False, 0)

		# create notebook and tabs
		hbox = Gtk.Box()
		hbox.set_property('orientation', Gtk.Orientation.HORIZONTAL)
		hbox.show()
		self.notebook = Gtk.Notebook()
		self.notebook.connect('switch-page', self._tab_changed)
		self.notebook.set_scrollable(True)
		hbox.pack_start(self.notebook, True, True, 0)
		vbox.pack_start(hbox, True, True, 0)

		self.tabs = {}
		current_page = self.notebook.get_current_page()
		self.last_page_id = current_page

		mailer_tab = MailSenderTab(self.config, self)
		self.tabs['mailer'] = mailer_tab
		self.notebook.insert_page(mailer_tab.box, mailer_tab.label, current_page + 1)
		self.notebook.set_current_page(current_page + 1)

		campaign_tab = CampaignViewTab(self.config, self)
		campaign_tab.box.show()
		self.tabs['campaign'] = campaign_tab
		self.notebook.insert_page(campaign_tab.box, campaign_tab.label, current_page + 2)

		self.set_size_request(800, 600)
		self.connect('destroy', self.signal_window_destroy)
		self.notebook.show()
		self.show()
		self.rpc = None
		self.ssh_forwarder = None

	def _add_menu_actions(self, action_group):
		# File Menu Actions
		action = Gtk.Action('FileMenu', 'File', None, None)
		action_group.add_action(action)

		action = Gtk.Action('FileOpenCampaign', '_Open Campaign', 'Open a Campaign', Gtk.STOCK_NEW)
		action.connect('activate', lambda x: self.show_campaign_selection())
		action_group.add_action_with_accel(action, '<control>O')

		action = Gtk.Action('FileExportMenu', 'Export', None, None)
		action_group.add_action(action)

		action = Gtk.Action('FileExportXML', 'Export XML', 'Export XML', None)
		action.connect('activate', lambda x: self.export_xml())
		action_group.add_action(action)

		action = Gtk.Action('FileQuit', None, None, Gtk.STOCK_QUIT)
		action.connect('activate', lambda x: self.client_quit())
		action_group.add_action_with_accel(action, '<control>Q')

		# Edit Menu Actions
		action = Gtk.Action('EditMenu', 'Edit', None, None)
		action_group.add_action(action)

		action = Gtk.Action('EditPreferences', 'Preferences', 'Edit preferences', Gtk.STOCK_EDIT)
		action.connect('activate', lambda x: self.edit_preferences())
		action_group.add_action(action)

		action = Gtk.Action('EditDeleteCampaign', 'Delete Campaign', 'Delete Campaign', None)
		action.connect('activate', lambda x: self.delete_campaign())
		action_group.add_action(action)

		action = Gtk.Action('EditStopService', 'Stop Service', 'Stop Remote King-Phisher Service', None)
		action.connect('activate', lambda x: self.stop_remote_service())
		action_group.add_action(action)

		# Tools Menu Action
		action = Gtk.Action('ToolsMenu', 'Tools', None, None)
		action_group.add_action(action)

		action = Gtk.Action('ToolsRPCTerminal', 'RPC Terminal', 'RPC Terminal', None)
		action.connect('activate', lambda x: KingPhisherClientRPCTerminal(self.config, self))
		action_group.add_action(action)

		# Help Menu Actions
		action = Gtk.Action('HelpMenu', 'Help', None, None)
		action_group.add_action(action)

		if graphs.has_matplotlib:
			action = Gtk.Action('ToolsGraphMenu', 'Create Graph', None, None)
			action_group.add_action(action)

			for graph_name in graphs.get_graphs():
				action_name = 'ToolsGraph' + graph_name
				action = Gtk.Action(action_name, graph_name, graph_name, None)
				action.connect('activate', lambda _: self.show_campaign_graph(graph_name))
				action_group.add_action(action)

		action = Gtk.Action('HelpAbout', 'About', 'About', None)
		action.connect('activate', lambda x: self.show_about_dialog())
		action_group.add_action(action)

		action = Gtk.Action('HelpWiki', 'Wiki', 'Wiki', None)
		action.connect('activate', lambda x: utilities.open_uri('https://github.com/securestate/king-phisher/wiki'))
		action_group.add_action(action)

	def _create_ui_manager(self):
		uimanager = Gtk.UIManager()
		with open(find.find_data_file('ui_info/client_window.xml')) as ui_info_file:
			ui_data = ui_info_file.read()
		uimanager.add_ui_from_string(ui_data)
		if graphs.has_matplotlib:
			merge_id = uimanager.new_merge_id()
			uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu', 'ToolsGraphMenu', Gtk.UIManagerItemType.MENU, False)
			for graph_name in graphs.get_graphs():
				action_name = 'ToolsGraph' + graph_name
				uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu', action_name, action_name, Gtk.UIManagerItemType.MENUITEM, False)
		accelgroup = uimanager.get_accel_group()
		self.add_accel_group(accelgroup)
		return uimanager

	def _tab_changed(self, notebook, current_page, index):
		previous_page = notebook.get_nth_page(self.last_page_id)
		self.last_page_id = index
		mailer_tab = self.tabs.get('mailer')
		campaign_tab = self.tabs.get('campaign')

		notebook = None
		if mailer_tab and current_page == mailer_tab.box:
			notebook = mailer_tab.notebook
		elif campaign_tab and current_page == campaign_tab.box:
			notebook = campaign_tab.notebook

		if notebook:
			index = notebook.get_current_page()
			notebook.emit('switch-page', notebook.get_nth_page(index), index)

	def do_campaign_set(self, campaign_id):
		self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id']))

	def signal_window_destroy(self, window):
		gui_utilities.gtk_widget_destroy_children(self)
		gui_utilities.gtk_sync()
		self.server_disconnect()
		self.save_config()
		Gtk.main_quit()
		return

	def init_connection(self):
		if not self.server_connect():
			return False
		self.load_server_config()
		campaign_id = self.config.get('campaign_id')
		if campaign_id == None:
			if not self.show_campaign_selection():
				self.server_disconnect()
				return False
		campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True)
		if campaign_info == None:
			if not self.show_campaign_selection():
				self.server_disconnect()
				return False
			campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True)
		self.config['campaign_name'] = campaign_info['name']
		self.emit('campaign_set', self.config['campaign_id'])
		return True

	def client_quit(self, destroy=True):
		self.destroy()
		return

	def server_connect(self):
		import socket
		server_version_info = None
		while True:
			if self.ssh_forwarder:
				self.ssh_forwarder.stop()
				self.ssh_forwarder = None
				self.logger.info('stopped ssh port forwarding')
			login_dialog = KingPhisherClientLoginDialog(self.config, self)
			login_dialog.objects_load_from_config()
			response = login_dialog.interact()
			if response == Gtk.ResponseType.CANCEL:
				return False
			server = utilities.server_parse(self.config['server'], 22)
			username = self.config['server_username']
			password = self.config['server_password']
			server_remote_port = self.config.get('server_remote_port', 80)
			local_port = random.randint(2000, 6000)
			try:
				self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config.get('ssh_preferred_key'))
				self.ssh_forwarder.start()
				time.sleep(0.5)
				self.logger.info('started ssh port forwarding')
			except paramiko.AuthenticationException:
				self.logger.warning('failed to authenticate to the remote ssh server')
				gui_utilities.show_dialog_error('Invalid Credentials', self)
				continue
			except:
				self.logger.warning('failed to connect to the remote ssh server')
				gui_utilities.show_dialog_error('Failed To Connect To The SSH Service', self)
				continue
			self.rpc = KingPhisherRPCClient(('localhost', local_port), username=username, password=password)
			try:
				server_version_info = self.rpc('version')
				assert(self.rpc('client/initialize'))
			except AdvancedHTTPServerRPCError as err:
				if err.status == 401:
					self.logger.warning('failed to authenticate to the remote king phisher service')
					gui_utilities.show_dialog_error('Invalid Credentials', self)
				else:
					self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status))
					gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self, 'The server responded with HTTP status: ' + str(err.status))
				continue
			except:
				self.logger.warning('failed to connect to the remote rpc service')
				gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self)
				continue
			break
		assert(server_version_info != None)
		server_rpc_api_version = server_version_info.get('rpc_api_version', -1)
		self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1})".format(server_version_info['version'], server_rpc_api_version))
		self.server_local_port = local_port
		if server_rpc_api_version != version.rpc_api_version:
			if version.rpc_api_version < server_rpc_api_version:
				secondary_text = 'The local client is not up to date with the server version.'
			else:
				secondary_text = 'The remote server is not up to date with the client version.'
			secondary_text += '\nPlease ensure that both the client and server are fully up to date.'
			gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', self, secondary_text)
			self.server_disconnect()
			return False
		return True

	def server_disconnect(self):
		if self.ssh_forwarder:
			self.ssh_forwarder.stop()
			self.ssh_forwarder = None
			self.logger.info('stopped ssh port forwarding')
		return

	def load_config(self):
		self.logger.info('loading the config from disk')
		config_file = os.path.expanduser(self.config_file)
		if not os.path.isfile(config_file):
			shutil.copy(find.find_data_file('client_config.json'), config_file)
		self.config = json.load(open(config_file, 'rb'))

	def load_server_config(self):
		self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image'])
		return

	def save_config(self):
		self.logger.info('writing the config to disk')
		config = copy.copy(self.config)
		for key in self.config.keys():
			if 'password' in key or key == 'server_config':
				del config[key]
		config_file = os.path.expanduser(self.config_file)
		config_file_h = open(config_file, 'wb')
		json.dump(config, config_file_h, sort_keys=True, indent=4)

	def delete_campaign(self):
		if not gui_utilities.show_dialog_yes_no('Delete This Campaign?', self, 'This action is irreversible. All campaign data will be lost.'):
			return
		self.rpc('campaign/delete', self.config['campaign_id'])
		if not self.show_campaign_selection():
			gui_utilities.show_dialog_error('A Campaign Must Be Selected', self, 'Now exiting')
			self.client_quit()

	def edit_preferences(self):
		dialog = KingPhisherClientConfigDialog(self.config, self)
		if dialog.interact() != Gtk.ResponseType.CANCEL:
			self.save_config()

	def export_xml(self):
		dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data', self)
		file_name = self.config['campaign_name'] + '.xml'
		response = dialog.run_quick_save(file_name)
		dialog.destroy()
		if not response:
			return
		destination_file = response['target_filename']
		export.campaign_to_xml(self.rpc, self.config['campaign_id'], destination_file)

	def show_about_dialog(self):
		license_text = None
		if os.path.splitext(__file__)[1] == '.py':
			source_file_h = open(__file__, 'r')
			source_code = []
			source_code.append(source_file_h.readline())
			while source_code[-1].startswith('#'):
				source_code.append(source_file_h.readline())
			source_code = source_code[5:-1]
			source_code = map(lambda x: x.strip('# '), source_code)
			license_text = ''.join(source_code)
		about_dialog = Gtk.AboutDialog()
		about_dialog.set_transient_for(self)
		about_dialog_properties = {
			'authors': ['Spencer McIntyre', 'Jeff McCutchan', 'Brandan Geise'],
			'comments': 'Phishing Campaign Toolkit',
			'copyright': '(c) 2013 SecureState',
			'license-type': Gtk.License.BSD,
			'program-name': 'King Phisher',
			'version': version.version,
			'website': 'https://github.com/securestate/king-phisher',
			'website-label': 'GitHub Home Page',
			'wrap-license': False,
		}
		if license_text:
			about_dialog_properties['license'] = license_text
		for property_name, property_value in about_dialog_properties.items():
			about_dialog.set_property(property_name, property_value)
		about_dialog.connect('activate-link', lambda _, url: utilities.open_uri(url))
		about_dialog.show_all()
		about_dialog.run()
		about_dialog.destroy()

	def show_campaign_graph(self, graph_name):
		Klass = graphs.get_graph(graph_name)
		graph_inst = Klass(self.config, self)
		graph_inst.load_graph()
		window = graph_inst.make_window()
		window.show_all()

	def show_campaign_selection(self):
		dialog = KingPhisherClientCampaignSelectionDialog(self.config, self)
		return dialog.interact() != Gtk.ResponseType.CANCEL

	def stop_remote_service(self):
		if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', self, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'):
			return
		self.rpc('shutdown')
		self.logger.info('the remote king phisher service has been stopped')
		gui_utilities.show_dialog_error('The Remote Service Has Been Stopped', self, 'Now exiting')
		self.client_quit()
		return