Put actual generation into separate thread.
[crypto-install.git] / crypto-install.py
index 22ca2d7..eec72f7 100755 (executable)
@@ -2,13 +2,15 @@
 # -*- mode: python; coding: utf-8-unix; -*-
 
 
-import argparse, errno, os, re, readline, subprocess, sys, tempfile, textwrap
+import argparse, errno, os, re, readline, subprocess, sys, tempfile, textwrap, threading
 
 
 if sys.version_info[0] == 2:
     from Tkinter import *
     from tkMessageBox import *
     from Tix import *
+    from ScrolledText import *
+    from Queue import *
 
     def input_string (prompt=""):
         return raw_input (prompt)
@@ -16,6 +18,8 @@ elif sys.version_info[0] > 2:
     from tkinter import *
     from tkinter.messagebox import *
     from tkinter.tix import *
+    from tkinter.scrolledtext import *
+    from queue import *
 
     def input_string (prompt=""):
         return input (prompt)
@@ -39,7 +43,7 @@ def lfilled (text):
     return textwrap.fill (ldedented (text), width = 72)
 
 
-def read_input_string (prompt="", default=""):
+def read_input_string (prompt = "", default = ""):
     if default != "":
         readline.set_startup_hook (lambda: readline.insert_text (default))
 
@@ -62,7 +66,8 @@ def parse_arguments ():
         dest = "gui",
         action = "store_false",
         help = "Disable GUI, use text mode.")
-    gnupg_group = parser.add_argument_group ("GnuPG",
+    gnupg_group = parser.add_argument_group (
+        "GnuPG",
         "Options related to the GnuPG setup.")
     gnupg_group.add_argument (
         "--no-gpg",
@@ -72,10 +77,11 @@ def parse_arguments ():
     gnupg_group.add_argument (
         "--gpg-home",
         dest = "gnupg_home",
-        default = "~/.gnupg",
+        default = os.getenv("GNUPGHOME") or "~/.gnupg",
         metavar = "PATH",
         help = "Default directory for GnuPG files.")
-    openssh_group = parser.add_argument_group ("OpenSSH",
+    openssh_group = parser.add_argument_group (
+        "OpenSSH",
         "Options related to the OpenSSH setup.")
     openssh_group.add_argument (
         "--no-ssh",
@@ -166,20 +172,21 @@ def input_passphrase (arguments):
     else:
         del batch_env["DISPLAY"]
 
-    batch_passphrase += "GET_PASSPHRASE --data --check --qualitybar X X Passphrase X\n"
+    batch_passphrase += \
+        "GET_PASSPHRASE --data --check --qualitybar X X Passphrase X\n"
 
     passphrase_process = subprocess.Popen (["gpg-agent", "--server"],
                                            stdin = subprocess.PIPE,
                                            stdout = subprocess.PIPE,
                                            stderr = subprocess.PIPE,
                                            env = batch_env)
-    (stdout, stderr) = passphrase_process.communicate (batch_passphrase)
+    (stdout, stderr) = passphrase_process.communicate (batch_passphrase.encode ("UTF-8"))
 
     if passphrase_process.returncode != 0:
         raise Exception ("Couldn't read passphrase.")
 
     for line in stdout.splitlines ():
-        if line.startswith ("D "):
+        if line.decode ("UTF-8").startswith ("D "):
             return line[2:]
 
     return ""
@@ -229,7 +236,7 @@ def gnupg_setup (arguments, name = None, email = None, comment = None):
         if comment != "":
             batch_key += "Name-Comment: {}\n".format (comment)
 
-        tmp.write (batch_key)
+        tmp.write (batch_key.encode ("UTF-8"))
         tmp.flush ()
 
         batch_env = dict (os.environ)
@@ -239,8 +246,21 @@ def gnupg_setup (arguments, name = None, email = None, comment = None):
         gnupg_process = subprocess.Popen (["gpg2",
                                            "--homedir", gnupg_home,
                                            "--batch", "--gen-key", tmp.name],
+                                          stdin = subprocess.PIPE,
+                                          stdout = subprocess.PIPE,
+                                          stderr = subprocess.STDOUT,
                                           env = batch_env)
-        gnupg_process.wait ()
+
+        # TODO: argh.  there has to be a better way
+        gnupg_process.stdin.close ()
+        while gnupg_process.poll () is None:
+            sys.stdout.write (gnupg_process.stdout.readline ())
+
+        while True:
+            line = gnupg_process.stdout.readline ()
+            if len (line) == 0:
+                break
+            sys.stdout.write (line.decode ("UTF-8"))
 
         if gnupg_process.returncode != 0:
             raise Exception ("Couldn't create GnuPG key.")
@@ -254,7 +274,8 @@ def openssh_setup (arguments, comment = None):
         print ("Creating OpenSSH directory at '{}'.".format (openssh_home))
         ensure_directories (openssh_home, 0o700)
 
-        print ("Creating OpenSSH configuration at '{}'.".format (openssh_config))
+        print ("Creating OpenSSH configuration at '{}'."
+               .format (openssh_config))
         with open (openssh_config, "w") as config:
             config.write (ldedented ("""
             ForwardAgent yes
@@ -267,6 +288,12 @@ def openssh_setup (arguments, comment = None):
         print ("OpenSSH key already exists at '{}'.".format (openssh_key))
         return
 
+    openssh_key_dsa = os.path.join (openssh_home, "id_dsa")
+
+    if os.path.exists (openssh_key_dsa):
+        print ("OpenSSH key already exists at '{}'.".format (openssh_key_dsa))
+        return
+
     print (filled ("No OpenSSH key available.  Generating new key."))
 
     if not arguments.gui:
@@ -276,17 +303,76 @@ def openssh_setup (arguments, comment = None):
 
     passphrase = input_passphrase (arguments)
 
+    batch_env = dict (os.environ)
+    if not arguments.gui:
+        del batch_env["DISPLAY"]
+
     # TODO: is it somehow possible to pass the password on stdin?
     openssh_process = subprocess.Popen (["ssh-keygen",
                                          "-P", passphrase,
                                          "-C", comment,
-                                         "-f", openssh_key])
-    openssh_process.wait ()
+                                         "-f", openssh_key],
+                                        stdin = subprocess.PIPE,
+                                        stdout = subprocess.PIPE,
+                                        stderr = subprocess.STDOUT,
+                                        env = batch_env)
+
+    # TODO: argh.  there has to be a better way
+    openssh_process.stdin.close ()
+    while openssh_process.poll () is None:
+        sys.stdout.write (openssh_process.stdout.readline ())
+
+    while True:
+        line = openssh_process.stdout.readline ()
+        if len (line) == 0:
+            break
+        sys.stdout.write (line.decode ("UTF-8"))
 
     if openssh_process.returncode != 0:
         raise Exception ("Couldn't create OpenSSH key.")
 
 
+# http://www.blog.pythonlibrary.org/2014/07/14/tkinter-redirecting-stdout-stderr/
+# http://www.virtualroadside.com/blog/index.php/2012/11/10/glib-idle_add-for-tkinter-in-python/
+class RedirectText (object):
+    def __init__ (self, root, widget):
+        self.root = root
+        self.widget = widget
+
+        self.queue = Queue ()
+
+    def write (self, string):
+        self.widget.insert (END, string)
+
+    def enqueue (self, value):
+        self.queue.put (value)
+        self.root.event_generate ("<<Idle>>", when = "tail")
+
+
+class CryptoInstallProgress (Toplevel):
+    def __init__ (self, parent):
+        Toplevel.__init__ (self, parent)
+
+        self.parent = parent
+
+        self.create_widgets ()
+
+    def create_widgets (self):
+        self.balloon = Balloon (self, initwait = 250)
+
+        self.text = ScrolledText (self)
+        self.text.pack (fill = BOTH, expand = True)
+
+        self.redirect = RedirectText (self.parent, self.text)
+
+        self._quit = Button (self)
+        self._quit["text"] = "Quit"
+        self._quit["command"] = self.quit
+        self.balloon.bind_widget (self._quit,
+                                  msg = "Quit the program immediately")
+        self._quit.pack ()
+
+
 class CryptoInstall (Tk):
     def __init__ (self, arguments):
         Tk.__init__ (self)
@@ -298,15 +384,12 @@ class CryptoInstall (Tk):
 
         self.create_widgets ()
 
-
     def create_widgets (self):
         self.balloon = Balloon (self, initwait = 250)
 
-
         self.info_frame = Frame (self)
         self.info_frame.pack (fill = X)
 
-
         self.user_label = Label (self.info_frame)
         self.user_label["text"] = "Username"
         self.user_label.grid ()
@@ -322,7 +405,6 @@ class CryptoInstall (Tk):
         """))
         self.user.grid (row = 0, column = 1)
 
-
         self.host_label = Label (self.info_frame)
         self.host_label["text"] = "Host Name"
         self.host_label.grid ()
@@ -338,7 +420,6 @@ class CryptoInstall (Tk):
         """))
         self.host.grid (row = 1, column = 1)
 
-
         self.name_label = Label (self.info_frame)
         self.name_label["text"] = "Full Name"
         self.name_label.grid ()
@@ -353,7 +434,6 @@ class CryptoInstall (Tk):
         """))
         self.name.grid (row = 2, column = 1)
 
-
         self.email_label = Label (self.info_frame)
         self.email_label["text"] = "Email address"
         self.email_label.grid ()
@@ -368,7 +448,6 @@ class CryptoInstall (Tk):
         """))
         self.email.grid (row = 3, column = 1)
 
-
         self.comment_label = Label (self.info_frame)
         self.comment_label["text"] = "Comment phrase"
         self.comment_label.grid ()
@@ -383,7 +462,6 @@ class CryptoInstall (Tk):
         """))
         self.comment.grid (row = 4, column = 1)
 
-
         self.options_frame = Frame (self)
         self.options_frame.pack (fill = X)
 
@@ -395,7 +473,8 @@ class CryptoInstall (Tk):
         self.gnupg_var.set (1 if self.arguments.gnupg else 0)
         self.gnupg_var.trace ("w", self.update_widgets)
 
-        self.gnupg = Checkbutton (self.options_frame, variable = self.gnupg_var)
+        self.gnupg = Checkbutton (self.options_frame,
+                                  variable = self.gnupg_var)
         self.gnupg.grid (row = 0, column = 1)
 
         self.openssh_label = Label (self.options_frame)
@@ -410,15 +489,15 @@ class CryptoInstall (Tk):
                                     variable = self.openssh_var)
         self.openssh.grid (row = 1, column = 1)
 
-
         self.button_frame = Frame (self)
         self.button_frame.pack (fill = X)
 
         self._generate = Button (self.button_frame)
         self._generate["text"] = "Generate Keys"
         self._generate["command"] = self.generate
-        self.balloon.bind_widget (self._generate,
-                                  msg = "Generate the keys as configured above")
+        self.balloon.bind_widget (
+            self._generate,
+            msg = "Generate the keys as configured above")
         self._generate.pack (side = LEFT, fill = Y)
 
         self._quit = Button (self.button_frame)
@@ -430,7 +509,6 @@ class CryptoInstall (Tk):
 
         self.update_widgets ()
 
-
     def valid_state (self):
         if not self.openssh_var.get () and not self.gnupg_var.get ():
             return False
@@ -449,7 +527,6 @@ class CryptoInstall (Tk):
 
         return True
 
-
     def update_widgets (self, *args):
         valid = self.valid_state ()
 
@@ -503,36 +580,54 @@ class CryptoInstall (Tk):
         self.balloon.bind_widget (self.openssh, msg = msg)
         self.balloon.bind_widget (self.openssh_label, msg = msg)
 
+    def generate_thread (self):
+        stdout = sys.stdout
+
+        try:
+            sys.stdout = self.progress.redirect
+
+            # TODO: capture and show stdout and stderr
+            if self.gnupg_var.get ():
+                # TODO: make get calls thread-safe
+                gnupg_setup (self.arguments,
+                             self.name_var.get (),
+                             self.email_var.get (),
+                             self.comment_var.get ())
+                # TODO: put update into queue
+                self.update_widgets ()
+
+            if self.openssh_var.get ():
+                comment = "{}@{}".format (self.user_var.get (),
+                                          self.host_var.get ())
+                openssh_setup (self.arguments, comment)
+                # TODO: put update into queue
+                self.update_widgets ()
+        finally:
+            sys.stdout = stdout
+
+    def _on_idle ():
+        while True:
+            try:
+                self.progress.redirect.write (self.progress.queue.get (block = False))
+            except Empty:
+                break
 
     def generate (self):
-        # TODO: capture and show stdout and stderr
-        if self.gnupg_var.get ():
-            gnupg_setup (self.arguments,
-                         self.name_var.get (),
-                         self.email_var.get (),
-                         self.comment_var.get ())
-
-        if self.openssh_var.get ():
-            comment = "{}@{}".format (self.user_var.get (),
-                                      self.host_var.get ())
-            openssh_setup (self.arguments, comment)
-
-        # TODO: show summary before exiting
-        self.quit ()
+        self.progress = CryptoInstallProgress (self)
 
+        self.bind ("<<Idle>>", self._on_idle)
 
-# TODO: use gtk instead?  would be more consistent with the pinentry style
-# (assuming it's using gtk)
-def gui (arguments):
-    app = CryptoInstall (arguments)
-    app.mainloop ()
+        thread = threading.Thread (target = self.generate_thread)
+        thread.start ()
 
 
 def main ():
     arguments = parse_arguments ()
 
     if arguments.gui:
-        gui (arguments)
+        # TODO: use gtk instead?  would be more consistent with the pinentry style
+        # (assuming it's using gtk)
+        CryptoInstall (arguments).mainloop ()
     else:
         if arguments.gnupg:
             gnupg_setup (arguments)