Add gettext support.
[crypto-install.git] / crypto-install
1 #!/usr/bin/env python
2 # -*- mode: python; coding: utf-8; -*-
3
4
5 import argparse, errno, gettext, os, re, readline, subprocess, sys, tempfile, textwrap, threading
6
7
8 if sys.version_info[0] == 2:
9     from Tkinter import *
10     from tkMessageBox import *
11     from Tix import *
12     from ScrolledText import *
13     from Queue import *
14
15
16     def input_string (prompt=""):
17         return raw_input (prompt)
18
19
20     def gettext_install ():
21         gettext.install ("crypto-install", unicode = True)
22 elif sys.version_info[0] > 2:
23     from tkinter import *
24     from tkinter.messagebox import *
25     from tkinter.tix import *
26     from tkinter.scrolledtext import *
27     from queue import *
28
29
30     def input_string (prompt=""):
31         return input (prompt)
32
33
34     def gettext_install ():
35         gettext.install ("crypto-install")
36 else:
37     raise Exception ("Unsupported Python version {}".format (sys.version_info))
38
39
40 def dedented (text):
41     return textwrap.dedent (text).strip ()
42
43
44 def ldedented (text):
45     return textwrap.dedent (text).lstrip ()
46
47
48 def filled (text):
49     return textwrap.fill (dedented (text), width = 72)
50
51
52 def lfilled (text):
53     return textwrap.fill (ldedented (text), width = 72)
54
55
56 def read_input_string (prompt = "", default = ""):
57     if default != "":
58         readline.set_startup_hook (lambda: readline.insert_text (default))
59
60     try:
61         return input_string(prompt)
62     finally:
63         readline.set_startup_hook()
64
65
66 def parse_arguments ():
67     parser = argparse.ArgumentParser ()
68     parser.add_argument (
69         "-v", "--version",
70         dest = "version",
71         action = "version",
72         version = "crypto-install.py version GIT-TAG (GIT-COMMIT/GIT-BRANCH)",
73         help = _ ("Display version."))
74     parser.add_argument (
75         "--no-gui",
76         dest = "gui",
77         action = "store_false",
78         help = _ ("Disable GUI, use text interface."))
79     gnupg_group = parser.add_argument_group (
80         _ ("GnuPG"),
81         _ ("Options related to the GnuPG setup."))
82     gnupg_group.add_argument (
83         "--no-gpg",
84         dest = "gnupg",
85         action = "store_false",
86         help = "Disable GnuPG setup.")
87     gnupg_group.add_argument (
88         "--gpg-home",
89         dest = "gnupg_home",
90         default = os.getenv("GNUPGHOME") or "~/.gnupg",
91         metavar = "PATH",
92         help = _ ("Default directory for GnuPG files."))
93     openssh_group = parser.add_argument_group (
94         _ ("OpenSSH"),
95         _ ("Options related to the OpenSSH setup."))
96     openssh_group.add_argument (
97         "--no-ssh",
98         dest = "openssh",
99         action = "store_false",
100         help = _ ("Disable OpenSSH setup."))
101     openssh_group.add_argument (
102         "--ssh-home",
103         dest = "openssh_home",
104         default = "~/.ssh",
105         metavar = "PATH",
106         help = _ ("Default directory for OpenSSH files."))
107     return parser.parse_args ()
108
109
110 def ensure_directories (path, mode = 0o777):
111     try:
112         os.makedirs (path, mode)
113     except OSError as exception:
114         if exception.errno != errno.EEXIST:
115             raise
116
117
118 def default_name ():
119     return os.getenv ("FULLNAME")
120
121
122 def default_email ():
123     return os.getenv ("EMAIL")
124
125
126 def default_comment ():
127     return ""
128
129
130 def default_hostname ():
131     return subprocess.check_output ("hostname").strip ()
132
133
134 def default_username ():
135     return os.getenv ("USER")
136
137
138 def valid_email (value):
139     return re.match (".+@.+", value)
140
141
142 def valid_name (value):
143     return value.strip () != ""
144
145
146 def valid_comment (value):
147     return True
148
149
150 def gnupg_exists (arguments):
151     gnupg_home = os.path.expanduser (arguments.gnupg_home)
152     gnupg_secring = os.path.join (gnupg_home, "secring.gpg")
153
154     return os.path.exists (gnupg_secring)
155
156
157 def openssh_exists (arguments):
158     openssh_home = os.path.expanduser (arguments.openssh_home)
159     openssh_config = os.path.join (openssh_home, "config")
160     openssh_key = os.path.join (openssh_home, "id_rsa")
161
162     return os.path.exists (openssh_config) and os.path.exists (openssh_key)
163
164
165 # TODO: verify phrase at least once
166 # TODO: use better labels
167 def input_passphrase (arguments):
168     batch_passphrase = ldedented ("""
169     RESET
170     OPTION ttyname={}
171     OPTION ttytype={}
172     """).format (subprocess.check_output ("tty").strip (),
173                  os.getenv ("TERM"))
174
175     batch_env = dict (os.environ)
176     if arguments.gui:
177         batch_passphrase += ldedented ("""
178         OPTION xauthority={}
179         OPTION display={}
180         """).format (os.getenv ("XAUTHORITY"),
181                      os.getenv ("DISPLAY"))
182     else:
183         del batch_env["DISPLAY"]
184
185     batch_passphrase += \
186         "GET_PASSPHRASE --data --check --qualitybar X X Passphrase X\n"
187
188     passphrase_process = subprocess.Popen (["gpg-agent", "--server"],
189                                            stdin = subprocess.PIPE,
190                                            stdout = subprocess.PIPE,
191                                            stderr = subprocess.PIPE,
192                                            env = batch_env)
193     (stdout, stderr) = passphrase_process.communicate (batch_passphrase.encode ("UTF-8"))
194
195     if passphrase_process.returncode != 0:
196         raise Exception ("Couldn't read passphrase.")
197
198     for line in stdout.splitlines ():
199         if line.decode ("UTF-8").startswith ("D "):
200             return line[2:]
201
202     return ""
203
204
205 def gnupg_setup (arguments, name = None, email = None, comment = None):
206     gnupg_home = os.path.expanduser (arguments.gnupg_home)
207     gnupg_secring = os.path.join (gnupg_home, "secring.gpg")
208
209     if gnupg_exists (arguments):
210         print (_ ("GnuPG secret keyring already exists at '{}'.")
211                .format (gnupg_secring))
212         return
213
214     if not arguments.gui:
215         print (filled (_ ("""
216         No default GnuPG key available.  Please enter your information to
217         create a new key.""")))
218
219         name = read_input_string (_ ("What is your name (e.g. 'John Doe')? "),
220                                   default_name ())
221
222         email = read_input_string (dedented (_ ("""
223         What is your email address (e.g. 'test@example.com')? """)),
224                                    default_email ())
225
226         comment = read_input_string (dedented (_ ("""
227         What is your comment phrase, if any (e.g. 'key for 2014')? """)),
228                                      default_comment ())
229
230     if not os.path.exists (gnupg_home):
231         print (_ ("Creating GnuPG directory at '{}'.").format (gnupg_home))
232         ensure_directories (gnupg_home, 0o700)
233
234     with tempfile.NamedTemporaryFile () as tmp:
235         batch_key = ldedented ("""
236         %ask-passphrase
237         Key-Type: DSA
238         Key-Length: 2048
239         Subkey-Type: ELG-E
240         Subkey-Length: 2048
241         Name-Real: {}
242         Name-Email: {}
243         Expire-Date: 0
244         """).format (name, email)
245
246         if comment != "":
247             batch_key += "Name-Comment: {}\n".format (comment)
248
249         tmp.write (batch_key.encode ("UTF-8"))
250         tmp.flush ()
251
252         batch_env = dict (os.environ)
253         if not arguments.gui:
254             del batch_env["DISPLAY"]
255
256         gnupg_process = subprocess.Popen (["gpg2",
257                                            "--homedir", gnupg_home,
258                                            "--batch", "--gen-key", tmp.name],
259                                           stdin = subprocess.PIPE,
260                                           stdout = subprocess.PIPE,
261                                           stderr = subprocess.STDOUT,
262                                           env = batch_env)
263
264         # TODO: argh.  there has to be a better way
265         gnupg_process.stdin.close ()
266         while gnupg_process.poll () is None:
267             sys.stdout.write (gnupg_process.stdout.readline ())
268
269         while True:
270             line = gnupg_process.stdout.readline ()
271             if len (line) == 0:
272                 break
273             sys.stdout.write (line.decode ("UTF-8"))
274
275         if gnupg_process.returncode != 0:
276             raise Exception ("Couldn't create GnuPG key.")
277
278
279 def openssh_setup (arguments, comment = None):
280     openssh_home = os.path.expanduser (arguments.openssh_home)
281     openssh_config = os.path.join (openssh_home, "config")
282
283     if not os.path.exists (openssh_config):
284         print (_ ("Creating OpenSSH directory at '{}'.").format (openssh_home))
285         ensure_directories (openssh_home, 0o700)
286
287         print (_ ("Creating OpenSSH configuration at '{}'.")
288                .format (openssh_config))
289         with open (openssh_config, "w") as config:
290             config.write (ldedented ("""
291             ForwardAgent yes
292             ForwardX11 yes
293             """))
294
295     openssh_key = os.path.join (openssh_home, "id_rsa")
296
297     if os.path.exists (openssh_key):
298         print (_ ("OpenSSH key already exists at '{}'.").format (openssh_key))
299         return
300
301     openssh_key_dsa = os.path.join (openssh_home, "id_dsa")
302
303     if os.path.exists (openssh_key_dsa):
304         print (_ ("OpenSSH key already exists at '{}'.").format (openssh_key_dsa))
305         return
306
307     print (filled (_ ("No OpenSSH key available.  Generating new key.")))
308
309     if not arguments.gui:
310         comment = "{}@{}".format (default_username (), default_hostname ())
311         comment = read_input_string (ldedented (_ ("""
312         What is your comment phrase (e.g. 'user@mycomputer')? """)), comment)
313
314     passphrase = input_passphrase (arguments)
315
316     batch_env = dict (os.environ)
317     if not arguments.gui:
318         del batch_env["DISPLAY"]
319
320     # TODO: is it somehow possible to pass the password on stdin?
321     openssh_process = subprocess.Popen (["ssh-keygen",
322                                          "-P", passphrase,
323                                          "-C", comment,
324                                          "-f", openssh_key],
325                                         stdin = subprocess.PIPE,
326                                         stdout = subprocess.PIPE,
327                                         stderr = subprocess.STDOUT,
328                                         env = batch_env)
329
330     # TODO: argh.  there has to be a better way
331     openssh_process.stdin.close ()
332     while openssh_process.poll () is None:
333         sys.stdout.write (openssh_process.stdout.readline ())
334
335     while True:
336         line = openssh_process.stdout.readline ()
337         if len (line) == 0:
338             break
339         sys.stdout.write (line.decode ("UTF-8"))
340
341     if openssh_process.returncode != 0:
342         raise Exception ("Couldn't create OpenSSH key.")
343
344
345 # http://www.blog.pythonlibrary.org/2014/07/14/tkinter-redirecting-stdout-stderr/
346 # http://www.virtualroadside.com/blog/index.php/2012/11/10/glib-idle_add-for-tkinter-in-python/
347 class RedirectText (object):
348     def __init__ (self, root, widget):
349         self.root = root
350         self.widget = widget
351
352         self.queue = Queue ()
353
354     def write (self, string):
355         self.widget.insert (END, string)
356
357     def enqueue (self, value):
358         self.queue.put (value)
359         self.root.event_generate ("<<Idle>>", when = "tail")
360
361
362 class CryptoInstallProgress (Toplevel):
363     def __init__ (self, parent):
364         Toplevel.__init__ (self, parent)
365
366         self.parent = parent
367
368         self.create_widgets ()
369
370     def create_widgets (self):
371         self.balloon = Balloon (self, initwait = 250)
372
373         self.text = ScrolledText (self)
374         self.text.pack (fill = BOTH, expand = True)
375
376         self.redirect = RedirectText (self.parent, self.text)
377
378         self._quit = Button (self)
379         self._quit["text"] = "Quit"
380         self._quit["command"] = self.quit
381         self.balloon.bind_widget (self._quit,
382                                   msg = _ ("Quit the program immediately"))
383         self._quit.pack ()
384
385
386 class CryptoInstall (Tk):
387     def __init__ (self, arguments):
388         Tk.__init__ (self)
389
390         self.arguments = arguments
391
392         self.resizable (width = False, height = False)
393         self.title (_ ("Crypto Install Wizard"))
394
395         self.create_widgets ()
396
397     def create_widgets (self):
398         self.balloon = Balloon (self, initwait = 250)
399
400         self.info_frame = Frame (self)
401         self.info_frame.pack (fill = X)
402
403         self.user_label = Label (self.info_frame)
404         self.user_label["text"] = _ ("Username")
405         self.user_label.grid ()
406
407         self.user_var = StringVar ()
408         self.user_var.set (default_username ())
409         self.user_var.trace ("w", self.update_widgets)
410
411         self.user = Entry (self.info_frame, textvariable = self.user_var,
412                            state = DISABLED)
413         self.balloon.bind_widget (self.user, msg = dedented (_ ("""
414         Username on the local machine (e.g. 'user')
415         """)))
416         self.user.grid (row = 0, column = 1)
417
418         self.host_label = Label (self.info_frame)
419         self.host_label["text"] = _ ("Host Name")
420         self.host_label.grid ()
421
422         self.host_var = StringVar ()
423         self.host_var.set (default_hostname ())
424         self.host_var.trace ("w", self.update_widgets)
425
426         self.host = Entry (self.info_frame, textvariable = self.host_var,
427                            state = DISABLED)
428         self.balloon.bind_widget (self.host, msg = dedented (_ ("""
429         Host name of the local machine (e.g. 'mycomputer')
430         """)))
431         self.host.grid (row = 1, column = 1)
432
433         self.name_label = Label (self.info_frame)
434         self.name_label["text"] = _ ("Full Name")
435         self.name_label.grid ()
436
437         self.name_var = StringVar ()
438         self.name_var.set (default_name ())
439         self.name_var.trace ("w", self.update_widgets)
440
441         self.name = Entry (self.info_frame, textvariable = self.name_var)
442         self.balloon.bind_widget (self.name, msg = dedented (_ ("""
443         Full name as it should appear in the key description (e.g. 'John Doe')
444         """)))
445         self.name.grid (row = 2, column = 1)
446
447         self.email_label = Label (self.info_frame)
448         self.email_label["text"] = _ ("Email address")
449         self.email_label.grid ()
450
451         self.email_var = StringVar ()
452         self.email_var.set (default_email ())
453         self.email_var.trace ("w", self.update_widgets)
454
455         self.email = Entry (self.info_frame, textvariable = self.email_var)
456         self.balloon.bind_widget (self.email, msg = dedented (_ ("""
457         Email address associated with the name (e.g. '<test@example.com>')
458         """)))
459         self.email.grid (row = 3, column = 1)
460
461         self.comment_label = Label (self.info_frame)
462         self.comment_label["text"] = _ ("Comment phrase")
463         self.comment_label.grid ()
464
465         self.comment_var = StringVar ()
466         self.comment_var.set (default_comment ())
467         self.comment_var.trace ("w", self.update_widgets)
468
469         self.comment = Entry (self.info_frame, textvariable = self.comment_var)
470         self.balloon.bind_widget (self.comment, msg = dedented (_ ("""
471         Comment phrase for the GnuPG key, if any (e.g. 'key for 2014')
472         """)))
473         self.comment.grid (row = 4, column = 1)
474
475         self.options_frame = Frame (self)
476         self.options_frame.pack (fill = X)
477
478         self.gnupg_label = Label (self.options_frame)
479         self.gnupg_label["text"] = _ ("Generate GnuPG key")
480         self.gnupg_label.grid ()
481
482         self.gnupg_var = IntVar ()
483         self.gnupg_var.set (1 if self.arguments.gnupg else 0)
484         self.gnupg_var.trace ("w", self.update_widgets)
485
486         self.gnupg = Checkbutton (self.options_frame,
487                                   variable = self.gnupg_var)
488         self.gnupg.grid (row = 0, column = 1)
489
490         self.openssh_label = Label (self.options_frame)
491         self.openssh_label["text"] = _ ("Generate OpenSSH key")
492         self.openssh_label.grid ()
493
494         self.openssh_var = IntVar ()
495         self.openssh_var.set (1 if self.arguments.openssh else 0)
496         self.openssh_var.trace ("w", self.update_widgets)
497
498         self.openssh = Checkbutton (self.options_frame,
499                                     variable = self.openssh_var)
500         self.openssh.grid (row = 1, column = 1)
501
502         self.button_frame = Frame (self)
503         self.button_frame.pack (fill = X)
504
505         self._generate = Button (self.button_frame)
506         self._generate["text"] = _ ("Generate Keys")
507         self._generate["command"] = self.generate
508         self.balloon.bind_widget (
509             self._generate,
510             msg = _ ("Generate the keys as configured above"))
511         self._generate.pack (side = LEFT, fill = Y)
512
513         self._quit = Button (self.button_frame)
514         self._quit["text"] = "Quit"
515         self._quit["command"] = self.quit
516         self.balloon.bind_widget (self._quit,
517                                   msg = _ ("Quit the program immediately"))
518         self._quit.pack (side = LEFT)
519
520         self.update_widgets ()
521
522     def valid_state (self):
523         if not self.openssh_var.get () and not self.gnupg_var.get ():
524             return False
525
526         if gnupg_exists (self.arguments) and openssh_exists (self.arguments):
527             return False
528
529         if not valid_email (self.email_var.get ()):
530             return False
531
532         if not valid_name (self.name_var.get ()):
533             return False
534
535         if not valid_comment (self.comment_var.get ()):
536             return False
537
538         return True
539
540     def update_widgets (self, *args):
541         valid = self.valid_state ()
542
543         self._generate["state"] = NORMAL if valid else DISABLED
544
545         name = self.name_var.get ()
546
547         valid = valid_name (name)
548         self.name["fg"] = "black" if valid else "red"
549         self.name_label["fg"] = "black" if valid else "red"
550
551         email = self.email_var.get ()
552
553         valid = valid_email (email)
554         self.email["fg"] = "black" if valid else "red"
555         self.email_label["fg"] = "black" if valid else "red"
556
557         comment = self.comment_var.get ()
558
559         valid = valid_comment (comment)
560         self.comment["fg"] = "black" if valid else "red"
561         self.comment_label["fg"] = "black" if valid else "red"
562
563         exists = gnupg_exists (self.arguments)
564         self.gnupg["state"] = NORMAL if not exists else DISABLED
565
566         exists = openssh_exists (self.arguments)
567         self.openssh["state"] = NORMAL if not exists else DISABLED
568
569         gnupg_key = name
570         if comment.strip () != "":
571             gnupg_key + " ({}) ".format (comment)
572         gnupg_key += "<{}>".format (email)
573
574         user = self.user_var.get ()
575         host = self.host_var.get ()
576
577         openssh_key = "{}@{}".format (user, host)
578
579         msg = dedented (_ ("""
580         Generate a GnuPG key for '{}' and configure a default setup for it
581         """)).format (gnupg_key)
582
583         self.balloon.bind_widget (self.gnupg, msg = msg)
584         self.balloon.bind_widget (self.gnupg_label, msg = msg)
585
586         msg = dedented (_ ("""
587         Generate an OpenSSH key for '{}' and configure a default setup for it
588         """)).format (openssh_key)
589
590         self.balloon.bind_widget (self.openssh, msg = msg)
591         self.balloon.bind_widget (self.openssh_label, msg = msg)
592
593     def generate_thread (self):
594         stdout = sys.stdout
595
596         try:
597             sys.stdout = self.progress.redirect
598
599             # TODO: capture and show stdout and stderr
600             if self.gnupg_var.get ():
601                 # TODO: make get calls thread-safe
602                 gnupg_setup (self.arguments,
603                              self.name_var.get (),
604                              self.email_var.get (),
605                              self.comment_var.get ())
606                 # TODO: put update into queue
607                 self.update_widgets ()
608
609             if self.openssh_var.get ():
610                 comment = "{}@{}".format (self.user_var.get (),
611                                           self.host_var.get ())
612                 openssh_setup (self.arguments, comment)
613                 # TODO: put update into queue
614                 self.update_widgets ()
615         finally:
616             sys.stdout = stdout
617
618     def _on_idle ():
619         while True:
620             try:
621                 self.progress.redirect.write (self.progress.queue.get (block = False))
622             except Empty:
623                 break
624
625     def generate (self):
626         self.progress = CryptoInstallProgress (self)
627
628         self.bind ("<<Idle>>", self._on_idle)
629
630         thread = threading.Thread (target = self.generate_thread)
631         thread.start ()
632
633
634 def main ():
635     gettext_install ()
636
637     arguments = parse_arguments ()
638
639     if arguments.gui:
640         # TODO: use gtk instead?  would be more consistent with the pinentry style
641         # (assuming it's using gtk)
642         CryptoInstall (arguments).mainloop ()
643     else:
644         if arguments.gnupg:
645             gnupg_setup (arguments)
646
647         if arguments.openssh:
648             openssh_setup (arguments)
649
650
651 if __name__ == "__main__":
652     main ()