whisperback (1.8.3)
[whisperback.git] / whisperBack / whisperback.py
blob0a4b4694add08533cff088c8456d4181dc086913
1 #!/usr/bin/env python3
2 # -*- coding: UTF-8 -*-
4 ########################################################################
5 # WhisperBack - Send feedback in an encrypted mail
6 # Copyright (C) 2009-2018 Tails developers <tails@boum.org>
8 # This file is part of WhisperBack
10 # WhisperBack is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 3 of the License, or (at
13 # your option) any later version.
15 # This program is distributed in the hope that it will be useful, but
16 # WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
18 # General Public License for more details.
20 # You should have received a copy of the GNU General Public License
21 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 ########################################################################
24 """WhisperBack main backend
26 """
28 import email.mime.text
29 import json
30 import logging
31 import os
32 import re
33 import threading
35 import gi
36 from gi.repository import GLib
38 # Import our modules
39 import whisperBack.exceptions
40 import whisperBack.mail
41 import whisperBack.encryption
42 import whisperBack.utils
44 LOG = logging.getLogger(__name__)
46 # pylint: disable=R0902
47 class WhisperBack(object):
48 """
49 This class contains the backend which actually sends the feedback
50 """
52 def set_contact_email(self, email):
53 """Sets an optional email address to be used for furether communication
55 """
57 LOG.debug("Setting contact email")
58 if whisperBack.utils.is_valid_email(email):
59 self._contact_email = email
60 else:
62 #XXX use a better exception
63 raise ValueError(_("Invalid contact email: %s" % email))
65 #pylint: disable=W0212
66 contact_email = property(lambda self: self._contact_email,
67 set_contact_email)
69 def set_contact_gpgkey(self, gpgkey):
70 """Sets an optional PGP key to be used for furether communication
72 """
74 LOG.debug("Setting PGP key")
75 if (whisperBack.utils.is_valid_pgp_block(gpgkey) or
76 whisperBack.utils.is_valid_pgp_id(gpgkey) or
77 whisperBack.utils.is_valid_link(gpgkey) or
78 gpgkey is ''):
79 self._contact_gpgkey = gpgkey
80 else:
81 #XXX use a better exception
82 if len(gpgkey.splitlines()) <= 1:
83 message = _("Invalid contact OpenPGP key: %s" % gpgkey)
84 else:
85 message = _("Invalid contact OpenPGP public key block")
86 raise ValueError(message)
88 #pylint: disable=W0212
89 contact_gpgkey = property(lambda self: self._contact_gpgkey,
90 set_contact_gpgkey)
92 def __init__(self, subject="", message=""):
93 """Initialize a feedback object with the given contents
95 @param subject The topic of the feedback
96 @param message The content of the feedback
97 """
98 self.__thread = None
99 self.__error_output = None
101 # Initialize config variables
102 self.gnupg_keyring = None
103 self.to_address = None
104 self.to_fingerprint = None
105 self.from_address = None
106 self.mail_prepended_info = lambda: ""
107 self.mail_appended_info = lambda: ""
108 self.mail_subject = None
109 self.smtp_host = None
110 self.smtp_port = None
111 self.socks_host = None
112 self.socks_port = None
114 # Load the python configuration file "config.py" from diffrents locations
115 # XXX: this is an absolute path, bad !
116 self.__load_conf(os.path.join("/", "etc", "whisperback", "config.py"))
117 self.__load_conf(os.path.join(os.path.expanduser('~'),
118 ".whisperback",
119 "config.py"))
120 self.__load_conf(os.path.join(os.getcwd(), "config.py"))
121 self.__check_conf()
123 # Get additional info through the callbacks and sanitize it
124 self.prepended_data = whisperBack.utils.sanitize_hardware_info(self.mail_prepended_info())
125 self.appended_data = self.__get_debug_info(self.mail_appended_info())
127 # Initialize other variables
128 self.subject = subject
129 self.message = message
130 self._contact_email = None
131 self._contact_gpgkey = None
132 self.send_attempts = 0
134 def __load_conf(self, config_file_path):
135 """Loads a configuration file from config_file_path and executes it
136 inside the current class.
138 @param config_file_path The path on the configuration file to load
141 LOG.debug('Loading conf from %s', config_file_path)
142 f = None
143 try:
144 f = open(config_file_path, 'r')
145 code = f.read()
146 except IOError:
147 # There's no problem if one of the configuration files is not
148 # present
149 LOG.warn("Failed to load conf %s", config_file_path)
150 return None
151 finally:
152 if f:
153 f.close()
154 #pylint: disable=W0122
155 exec(code, self.__dict__)
157 def __get_debug_info(self, raw_debug, prefix=''):
158 """ Deserializes the dicts from raw_debug and creates a string
159 with the header from the dict key and it's content
161 @param raw_debug The serialized json containing the debug info
162 It is a list of dicts to keep the order of the different debug infos
164 all_info = json.loads(raw_debug)
165 result = ''
166 for debug_info in all_info:
167 if prefix:
168 result += '\n{} === content of {} ===\n'.format(prefix, debug_info['key'])
169 else:
170 result += '\n======= content of {} =======\n'.format(debug_info['key'])
171 if type(debug_info['content']) is list:
172 for line in debug_info['content']:
174 if isinstance(line, dict):
175 result += self.__get_debug_info(json.dumps([line]), prefix + '> ')
176 else:
177 sanitized = '{}{}\n'.format(prefix, whisperBack.utils.sanitize_hardware_info(line))
178 result += re.sub(r'^--\s*', '', sanitized)
179 else:
180 result += '{}{}\n'.format(prefix, whisperBack.utils.sanitize_hardware_info(debug_info['content']))
181 return result
183 def __check_conf(self):
184 """Check that all the required configuration variables are filled
185 and raise MisconfigurationException if not.
187 LOG.debug("Checking conf")
188 # XXX: Add sanity checks
190 if not self.to_address:
191 raise whisperBack.exceptions.MisconfigurationException('to_address')
192 if not self.to_fingerprint:
193 raise whisperBack.exceptions.MisconfigurationException('to_fingerprint')
194 if not self.from_address:
195 raise whisperBack.exceptions.MisconfigurationException('from_address')
196 if not self.mail_subject:
197 raise whisperBack.exceptions.MisconfigurationException('mail_subject')
198 if not self.smtp_host:
199 raise whisperBack.exceptions.MisconfigurationException('smtp_host')
200 if not self.smtp_port:
201 raise whisperBack.exceptions.MisconfigurationException('smtp_port')
202 if not self.socks_host:
203 raise whisperBack.exceptions.MisconfigurationException('socks_host')
204 if not self.socks_port:
205 raise whisperBack.exceptions.MisconfigurationException('socks_port')
207 if not whisperBack.utils.is_valid_hostname_or_ipv4(self.smtp_host):
208 raise ValueError("Invalid value for 'smtp_host'.")
209 if not whisperBack.utils.is_valid_port(self.smtp_port):
210 raise ValueError("Invalid value for 'smtp_port'.")
211 if not whisperBack.utils.is_valid_hostname_or_ipv4(self.socks_host):
212 raise ValueError("Invalid value for 'socks_host'.")
213 if not whisperBack.utils.is_valid_port(self.socks_port):
214 raise ValueError("Invalid value for 'socks_port'.")
216 def execute_threaded(self, func, args, progress_callback=None,
217 finished_callback=None, polling_freq=100):
218 """Execute a function in another thread and handle it.
220 Execute the function `func` with arguments `args` in another thread,
221 and poll whether the thread is alive, executing the callback
222 `progress_callback` every `polling_frequency`. When the function
223 thread terminates, saves the execption it eventually raised and pass
224 it to `finished_callback`.
226 @param func the function to execute.
227 @param args the tuple to pass as arguments to `func`.
228 @param progress_callback (optional) a callback function to call
229 every time the execution thread is polled.
230 It doesn't take any agument.
231 @param finished_callback (optional) a callback function to call when
232 the execution thread terminated. It receives
233 the exception raised by `func`, if any, or
234 None.
235 @param polling_freq (optional) the interal between polling
236 iterations (in ms).
238 #pylint: disable=C0111
239 def save_exception(func, args):
240 try:
241 #pylint: disable=W0142
242 func(*args)
243 except Exception as e:
244 self.__error_output = e
245 raise
247 def poll_thread(self):
248 if progress_callback is not None:
249 progress_callback()
250 if self.__thread.isAlive():
251 return True
252 else:
253 if finished_callback is not None:
254 finished_callback(self.__error_output)
255 return False
257 self.__error_output = None
258 assert self.__thread is None or not self.__thread.isAlive()
259 self.__thread = threading.Thread(target=save_exception, args=(func, args))
260 self.__thread.start()
261 # XXX: there could be no main loop
262 GLib.timeout_add(polling_freq, poll_thread, self)
263 # XXX: static would be best, but I get a problem with self.*
264 #execute_threaded = staticmethod(execute_threaded)
266 def get_message_body(self):
267 """Returns the content of the message body
269 Aggregate all informations to prepare the message body.
271 LOG.debug("Creating message body")
272 body = "Subject: %s\n" % self.subject
273 if self.contact_email:
274 body += "From: %s\n" % self.contact_email
275 if self.contact_gpgkey:
276 # Test whether we have a key block or a key id/url
277 if len(self.contact_gpgkey.splitlines()) <= 1:
278 body += "OpenPGP-Key: %s\n" % self.contact_gpgkey
279 else:
280 body += "OpenPGP-Key: included below\n"
281 body += "%s\n%s\n\n" % (self.prepended_data, self.message)
282 if self.contact_gpgkey and len(self.contact_gpgkey.splitlines()) > 1:
283 body += "%s\n\n" % self.contact_gpgkey
284 body += "%s\n" % self.appended_data
285 return body
287 def get_mime_message(self):
288 """Returns the PGP/MIME message to be sent"""
289 LOG.debug("Building mime message")
290 mime_message = email.mime.text.MIMEText(self.get_message_body())
292 encrypter = whisperBack.encryption.Encryption(
293 keyring=self.gnupg_keyring)
295 encrypted_mime_message = encrypter.pgp_mime_encrypt(
296 mime_message,
297 [self.to_fingerprint])
299 encrypted_mime_message['Subject'] = self.mail_subject
300 encrypted_mime_message['From'] = self.from_address
301 encrypted_mime_message['To'] = self.to_address
303 return encrypted_mime_message
305 def save(self, path):
306 """Save the message into a file
308 @param path path of the file to save
310 f = open(path, 'w')
311 try:
312 f.write(str(self.get_mime_message()))
313 finally:
314 f.close()
316 def send(self, progress_callback=None, finished_callback=None):
317 """Actually sends the message
319 @param progress_callback
320 @param finished_callback
322 LOG.debug("Sending message")
323 # XXX: It's really strange that some exceptions from this method are
324 # raised and some other transmitted to finished_callback…
326 self.send_attempts = self.send_attempts + 1
328 mime_message = self.get_mime_message().as_string()
330 self.execute_threaded(func=whisperBack.mail.send_message,
331 args=(self.from_address, self.to_address,
332 mime_message, self.smtp_host,
333 self.smtp_port, self.socks_host,
334 self.socks_port),
335 progress_callback=progress_callback,
336 finished_callback=finished_callback)