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
28 import email
.mime
.text
36 from gi
.repository
import GLib
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):
49 This class contains the backend which actually sends the feedback
52 def set_contact_email(self
, email
):
53 """Sets an optional email address to be used for furether communication
57 LOG
.debug("Setting contact email")
58 if whisperBack
.utils
.is_valid_email(email
):
59 self
._contact
_email
= email
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
,
69 def set_contact_gpgkey(self
, gpgkey
):
70 """Sets an optional PGP key to be used for furether communication
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
79 self
._contact
_gpgkey
= gpgkey
81 #XXX use a better exception
82 if len(gpgkey
.splitlines()) <= 1:
83 message
= _("Invalid contact OpenPGP key: %s" % gpgkey
)
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
,
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
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('~'),
120 self
.__load
_conf
(os
.path
.join(os
.getcwd(), "config.py"))
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
)
144 f
= open(config_file_path
, 'r')
147 # There's no problem if one of the configuration files is not
149 LOG
.warn("Failed to load conf %s", config_file_path
)
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
)
166 for debug_info
in all_info
:
168 result
+= '\n{} === content of {} ===\n'.format(prefix
, debug_info
['key'])
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
+ '> ')
177 sanitized
= '{}{}\n'.format(prefix
, whisperBack
.utils
.sanitize_hardware_info(line
))
178 result
+= re
.sub(r
'^--\s*', '', sanitized
)
180 result
+= '{}{}\n'.format(prefix
, whisperBack
.utils
.sanitize_hardware_info(debug_info
['content']))
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
235 @param polling_freq (optional) the interal between polling
238 #pylint: disable=C0111
239 def save_exception(func
, args
):
241 #pylint: disable=W0142
243 except Exception as e
:
244 self
.__error
_output
= e
247 def poll_thread(self
):
248 if progress_callback
is not None:
250 if self
.__thread
.isAlive():
253 if finished_callback
is not None:
254 finished_callback(self
.__error
_output
)
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
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
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(
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
312 f
.write(str(self
.get_mime_message()))
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
,
335 progress_callback
=progress_callback
,
336 finished_callback
=finished_callback
)