Index: trunk/host/credit-card/host.py
===================================================================
--- trunk/host/credit-card/host.py	(revision 2066)
+++ trunk/host/credit-card/host.py	(revision 2066)
@@ -0,0 +1,184 @@
+import os
+import optparse
+import socket
+import tempfile
+import shutil
+import errno
+import csv
+
+import shell
+
+HOST = socket.gethostname()
+
+# XXX test server and wizard server
+
+# UIDs (sketchy):
+#   signup 102
+#   fedora-ds 103 (sketchy, not true for b-b)
+#   logview 501 (really sketchy, since it's in the dynamic range)
+
+# Works for passwd and group, but be careful! They're different things!
+def lookup(filename):
+    # Super-safe to assume and volume IDs (expensive to check)
+    r = {
+        'root': 0,
+        'sql': 537704221,
+    }
+    with open(filename, 'rb') as f:
+        reader = csv.reader(f, delimiter=':', quoting=csv.QUOTE_NONE)
+        for row in reader:
+            r[row[0]] = int(row[2])
+    return r
+
+# Format here assumes that we always chmod $USER:$USER ...
+# but note the latter refers to group...
+COMMON_CREDS = [
+    ('root', 0o600, 'root/.bashrc'),
+    ('root', 0o600, 'root/.screenrc'),
+    ('root', 0o600, 'root/.ssh/authorized_keys'),
+    ('root', 0o600, 'root/.ssh/authorized_keys2'),
+    ('root', 0o600, 'root/.vimrc'),
+    ('root', 0o600, 'root/.k5login'),
+    # punted /root/.ssh/known_hosts
+
+    # XXX user must be created in Kickstart
+    ('logview', 0o600, 'home/logview/.k5login'),
+    ]
+
+COMMON_PROD_CREDS = [ # important: no leading slashes!
+    ('root', 0o600, 'root/.ldapvirc'),
+    ('root', 0o600, 'etc/ssh/ssh_host_dsa_key'),
+    ('root', 0o600, 'etc/ssh/ssh_host_key'),
+    ('root', 0o600, 'etc/ssh/ssh_host_rsa_key'),
+    ('root', 0o600, 'etc/pki/tls/private/scripts-1024.key'),
+    ('root', 0o600, 'etc/pki/tls/private/scripts.key'),
+    ('root', 0o600, 'etc/whoisd-password'),
+    ('afsagent', 0o600, 'etc/daemon.keytab'),
+
+    ('root', 0o644, 'etc/ssh/ssh_host_dsa_key.pub'),
+    ('root', 0o644, 'etc/ssh/ssh_host_key.pub'),
+    ('root', 0o644, 'etc/ssh/ssh_host_rsa_key.pub'),
+
+    ('sql', 0o600, 'etc/sql-mit-edu.cfg.php'),
+    ('signup', 0o600, 'etc/signup-ldap-pw'),
+    ]
+
+MACHINE_PROD_CREDS = [
+    # XXX NEED TO CHECK THAT THESE ARE SENSIBLE
+    ('root', 0o600, 'etc/krb5.keytab'),
+    ('fedora-ds', 0o600, 'etc/dirsrv/keytab')
+    ]
+
+def mkdir_p(path): # it's like mkdir -p
+    try:
+        os.makedirs(path)
+    except OSError as e:
+        if e.errno == errno.EEXIST:
+            pass
+        else: raise
+
+# XXX This code is kind of dangerous, because we are directly using the
+# kernel modules to manipulate possibly untrusted disk images.  This
+# means that if an attacker can corrupt the disk, and exploit a problem
+# in the kernel vfs driver, he can escalate a guest root exploit
+# to a host root exploit.  Ultimately we should use libguestfs
+# which makes this attack harder to pull off, but at the time of writing
+# squeeze didn't package libguestfs.
+#
+# We try to minimize attack surface by explicitly specifying the
+# expected filesystem type.
+class WithMount(object):
+    """Context for running code with an extra mountpoint."""
+    guest = None
+    types = None # comma separated, like the mount argument -t
+    mount = None
+    dev = None
+    def __init__(self, guest, types):
+        self.guest = guest
+        self.types = types
+    def __enter__(self):
+        self.dev = "/dev/%s/%s-root" % (HOST, self.guest)
+
+        mapper_name = shell.eval("kpartx", "-l", self.dev).split()[0]
+        shell.call("kpartx", "-a", self.dev)
+        mapper = "/dev/mapper/%s" % mapper_name
+
+        # this is why bracketing functions and hanging lambdas are a good idea
+        try:
+            self.mount = tempfile.mkdtemp("-%s" % self.guest, 'vm-', '/mnt') # no trailing slash
+            try:
+                shell.call("mount", "--types", self.types, mapper, self.mount)
+            except:
+                os.rmdir(self.mount)
+                raise
+        except:
+            shell.call("kpartx", "-d", self.dev)
+            raise
+
+        return self.mount
+    def __exit__(self, _type, _value, _traceback):
+        shell.call("umount", self.mount)
+        os.rmdir(self.mount)
+        shell.call("kpartx", "-d", self.dev)
+
+def main():
+    usage = """usage: %prog [push|pull|pull-common] GUEST"""
+
+    parser = optparse.OptionParser(usage)
+    # ext3 will probably supported for a while yet and a pretty
+    # reasonable thing to always try
+    parser.add_option('-t', '--types', dest="types", default="ext4,ext3",
+            help="filesystem type(s)")
+    parser.add_option('--creds-dir', dest="creds_dir", default="/root/creds",
+            help="directory to store/fetch credentials in")
+    options, args = parser.parse_args()
+
+    if not os.path.isdir(options.creds_dir):
+        raise Exception("/root/creds does not exist") # XXX STRING
+    # XXX check owned by root and appropriately chmodded
+
+    os.umask(0o077) # overly restrictive
+
+    if len(args) != 2:
+        parser.print_help()
+        raise Exception("Wrong number of arguments")
+
+    command = args[0]
+    guest   = args[1]
+
+    with WithMount(guest, options.types) as tmp_mount:
+        uid_lookup = lookup("%s/etc/passwd" % tmp_mount)
+        gid_lookup = lookup("%s/etc/group" % tmp_mount)
+        def push_files(files, type):
+            for (usergroup, perms, f) in files:
+                dest = "%s/%s" % (tmp_mount, f)
+                mkdir_p(os.path.dirname(dest)) # useful for .ssh
+                # assuming OK to overwrite
+                # XXX we could compare the files before doing anything...
+                shutil.copyfile("%s/%s/%s" % (options.creds_dir, type, f), dest)
+                try:
+                    os.chown(dest, uid_lookup[usergroup], gid_lookup[usergroup])
+                    os.chmod(dest, perms)
+                except:
+                    # never ever leave un-chowned files lying around
+                    os.unlink(dest)
+                    raise
+        def pull_files(files, type):
+            for (_, _, f) in files:
+                dest = "%s/%s/%s" % (options.creds_dir, type, f)
+                mkdir_p(os.path.dirname(dest))
+                # error if doesn't exist
+                shutil.copyfile("%s/%s" % (tmp_mount, f), dest)
+
+        if command == "push":
+            push_files(COMMON_CREDS, 'common')
+            push_files(COMMON_PROD_CREDS,  'common')
+            push_files(MACHINE_PROD_CREDS, 'machine/%s' % guest)
+        elif command == "pull":
+            pull_files(MACHINE_PROD_CREDS, 'machine/%s' % guest)
+        elif command == "pull-common":
+            pull_files(COMMON_CREDS, 'common')
+            pull_files(COMMON_PROD_CREDS,  'common')
+
+if __name__ == "__main__":
+    main()
Index: trunk/host/credit-card/shell.py
===================================================================
--- trunk/host/credit-card/shell.py	(revision 2066)
+++ trunk/host/credit-card/shell.py	(revision 2066)
@@ -0,0 +1,301 @@
+"""
+Wrappers around subprocess functionality that simulate an actual shell.
+"""
+
+import subprocess
+import logging
+import sys
+import os
+import errno
+
+class Shell(object):
+    """
+    An advanced shell that performs logging.  If ``dry`` is ``True``,
+    no commands are actually run.
+    """
+    def __init__(self, dry = False):
+        self.dry = dry
+        self.cwd = None
+    def call(self, *args, **kwargs):
+        """
+        Performs a system call.  The actual executable and options should
+        be passed as arguments to this function.  Several keyword arguments
+        are also supported:
+
+        :param input: input to feed the subprocess on standard input.
+        :param interactive: whether or not directly hook up all pipes
+            to the controlling terminal, to allow interaction with subprocess.
+        :param strip: if ``True``, instead of returning a tuple,
+            return the string stdout output of the command with trailing newlines
+            removed.  This emulates the behavior of backticks and ``$()`` in Bash.
+            Prefer to use :meth:`eval` instead (you should only need to explicitly
+            specify this if you are using another wrapper around this function).
+        :param log: if True, we log the call as INFO, if False, we log the call
+            as DEBUG, otherwise, we detect based on ``strip``.
+        :param stdout:
+        :param stderr:
+        :param stdin: a file-type object that will be written to or read from as a pipe.
+        :returns: a tuple of strings ``(stdout, stderr)``, or a string ``stdout``
+            if ``strip`` is specified.
+
+        >>> sh = Shell()
+        >>> sh.call("echo", "Foobar")
+        ('Foobar\\n', '')
+        >>> sh.call("cat", input='Foobar')
+        ('Foobar', '')
+        """
+        self._wait()
+        kwargs.setdefault("interactive", False)
+        kwargs.setdefault("strip", False)
+        kwargs.setdefault("python", None)
+        kwargs.setdefault("log", None)
+        kwargs.setdefault("stdout", subprocess.PIPE)
+        kwargs.setdefault("stdin", subprocess.PIPE)
+        kwargs.setdefault("stderr", subprocess.PIPE)
+        msg = "Running `" + ' '.join(args) + "`"
+        if kwargs["strip"] and not kwargs["log"] is True or kwargs["log"] is False:
+            logging.debug(msg)
+        else:
+            logging.info(msg)
+        if self.dry:
+            if kwargs["strip"]:
+                return ''
+            return None, None
+        kwargs.setdefault("input", None)
+        if kwargs["interactive"]:
+            stdout=sys.stdout
+            stdin=sys.stdin
+            stderr=sys.stderr
+        else:
+            stdout=kwargs["stdout"]
+            stdin=kwargs["stdin"]
+            stderr=kwargs["stderr"]
+        # XXX: There is a possible problem here where we can fill up
+        # the kernel buffer if we have 64KB of data.  This shouldn't
+        # be a problem, and the fix for such case would be to write to
+        # temporary files instead of a pipe.
+        # Another possible way of fixing this is converting from a
+        # waitpid() pump to a select() pump, creating a pipe to
+        # ourself, and then setting up a
+        # SIGCHILD handler to write a single byte to the pipe to get
+        # us out of select() when a subprocess exits.
+        proc = subprocess.Popen(args, stdout=stdout, stderr=stderr, stdin=stdin, cwd=self.cwd, )
+        if self._async(proc, args, **kwargs):
+            return proc
+        stdout, stderr = proc.communicate(kwargs["input"])
+        # can occur if we were doing interactive communication; i.e.
+        # we didn't pass in PIPE.
+        if stdout is None:
+            stdout = ""
+        if stderr is None:
+            stderr = ""
+        if not kwargs["interactive"]:
+            if kwargs["strip"]:
+                self._log(None, stderr)
+            else:
+                self._log(stdout, stderr)
+        if proc.returncode:
+            raise CallError(proc.returncode, args, stdout, stderr)
+        if kwargs["strip"]:
+            return str(stdout).rstrip("\n")
+        return (stdout, stderr)
+    def _log(self, stdout, stderr):
+        """Logs the standard output and standard input from a command."""
+        if stdout:
+            logging.debug("STDOUT:\n" + stdout)
+        if stderr:
+            logging.debug("STDERR:\n" + stderr)
+    def _wait(self):
+        pass
+    def _async(self, *args, **kwargs):
+        return False
+    def callAsUser(self, *args, **kwargs):
+        """
+        Performs a system call as a different user.  This is only possible
+        if you are running as root.  Keyword arguments
+        are the same as :meth:`call` with the following additions:
+
+        :param user: name of the user to run command as.
+        :param uid: uid of the user to run command as.
+
+        .. note::
+
+            The resulting system call internally uses :command:`sudo`,
+            and as such environment variables will get scrubbed.  We
+            manually preserve :envvar:`SSH_GSSAPI_NAME`.
+        """
+        user = kwargs.pop("user", None)
+        uid = kwargs.pop("uid", None)
+        if not user and not uid: return self.call(*args, **kwargs)
+        if os.getenv("SSH_GSSAPI_NAME"):
+            # This might be generalized as "preserve some environment"
+            args = list(args)
+            args.insert(0, "SSH_GSSAPI_NAME=" + os.getenv("SSH_GSSAPI_NAME"))
+        if uid: return self.call("sudo", "-u", "#" + str(uid), *args, **kwargs)
+        if user: return self.call("sudo", "-u", user, *args, **kwargs)
+    def safeCall(self, *args, **kwargs):
+        """
+        Checks if the owner of the current working directory is the same
+        as the current user, and if it isn't, attempts to sudo to be
+        that user.  The intended use case is for calling Git commands
+        when running as root, but this method should be used when
+        interfacing with any moderately complex program that depends
+        on working directory context.  Keyword arguments are the
+        same as :meth:`call`.
+        """
+        if os.getuid():
+            return self.call(*args, **kwargs)
+        uid = os.stat(os.getcwd()).st_uid
+        # consider also checking ruid?
+        if uid != os.geteuid():
+            kwargs['uid'] = uid
+            return self.callAsUser(*args, **kwargs)
+        else:
+            return self.call(*args, **kwargs)
+    def eval(self, *args, **kwargs):
+        """
+        Evaluates a command and returns its output, with trailing newlines
+        stripped (like backticks in Bash).  This is a convenience method for
+        calling :meth:`call` with ``strip``.
+
+            >>> sh = Shell()
+            >>> sh.eval("echo", "Foobar") 
+            'Foobar'
+        """
+        kwargs["strip"] = True
+        return self.call(*args, **kwargs)
+    def setcwd(self, cwd):
+        """
+        Sets the directory processes are executed in. This sets a value
+        to be passed as the ``cwd`` argument to ``subprocess.Popen``.
+        """
+        self.cwd = cwd
+
+class ParallelShell(Shell):
+    """
+    Modifies the semantics of :class:`Shell` so that
+    commands are queued here, and executed in parallel using waitpid
+    with ``max`` subprocesses, and result in callback execution
+    when they finish.
+
+    .. method:: call(*args, **kwargs)
+
+        Enqueues a system call for parallel processing.  If there are
+        no openings in the queue, this will block.  Keyword arguments
+        are the same as :meth:`Shell.call` with the following additions:
+
+        :param on_success: Callback function for success (zero exit status).
+            The callback function should accept two arguments,
+            ``stdout`` and ``stderr``.
+        :param on_error: Callback function for failure (nonzero exit status).
+            The callback function should accept one argument, the
+            exception that would have been thrown by the synchronous
+            version.
+        :return: The :class:`subprocess.Proc` object that was opened.
+
+    .. method:: callAsUser(*args, **kwargs)
+
+        Enqueues a system call under a different user for parallel
+        processing.  Keyword arguments are the same as
+        :meth:`Shell.callAsUser` with the additions of keyword
+        arguments from :meth:`call`.
+
+    .. method:: safeCall(*args, **kwargs)
+
+        Enqueues a "safe" call for parallel processing.  Keyword
+        arguments are the same as :meth:`Shell.safeCall` with the
+        additions of keyword arguments from :meth:`call`.
+
+    .. method:: eval(*args, **kwargs)
+
+        No difference from :meth:`call`.  Consider having a
+        non-parallel shell if the program you are shelling out
+        to is fast.
+
+    """
+    def __init__(self, dry = False, max = 10):
+        super(ParallelShell, self).__init__(dry=dry)
+        self.running = {}
+        self.max = max # maximum of commands to run in parallel
+    @staticmethod
+    def make(no_parallelize, max):
+        """Convenience method oriented towards command modules."""
+        if no_parallelize:
+            return DummyParallelShell()
+        else:
+            return ParallelShell(max=max)
+    def _async(self, proc, args, python, on_success, on_error, **kwargs):
+        """
+        Gets handed a :class:`subprocess.Proc` object from our deferred
+        execution.  See :meth:`Shell.call` source code for details.
+        """
+        self.running[proc.pid] = (proc, args, python, on_success, on_error)
+        return True # so that the parent function returns
+    def _wait(self):
+        """
+        Blocking call that waits for an open subprocess slot.  This is
+        automatically called by :meth:`Shell.call`.
+        """
+        # XXX: This API sucks; the actual call/callAsUser call should
+        # probably block automatically (unless I have a good reason not to)
+        # bail out immediately on initial ramp up
+        if len(self.running) < self.max: return
+        # now, wait for open pids.
+        try:
+            self.reap(*os.waitpid(-1, 0))
+        except OSError as e:
+            if e.errno == errno.ECHILD: return
+            raise
+    def join(self):
+        """Waits for all of our subprocesses to terminate."""
+        try:
+            while True:
+                self.reap(*os.waitpid(-1, 0))
+        except OSError as e:
+            if e.errno == errno.ECHILD: return
+            raise
+    def reap(self, pid, status):
+        """Reaps a process."""
+        # ooh, zombie process. reap it
+        proc, args, python, on_success, on_error = self.running.pop(pid)
+        # XXX: this is slightly dangerous; should actually use
+        # temporary files
+        stdout = proc.stdout.read()
+        stderr = proc.stderr.read()
+        self._log(stdout, stderr)
+        if status:
+            on_error(CallError(proc.returncode, args, stdout, stderr))
+            return
+        on_success(stdout, stderr)
+
+# Setup a convenience global instance
+shell = Shell()
+call = shell.call
+callAsUser = shell.callAsUser
+safeCall = shell.safeCall
+eval = shell.eval
+
+class DummyParallelShell(ParallelShell):
+    """Same API as :class:`ParallelShell`, but doesn't actually
+    parallelize (i.e. all calls to :meth:`wait` block.)"""
+    def __init__(self, dry = False):
+        super(DummyParallelShell, self).__init__(dry=dry, max=1)
+
+class CallError:
+    """Indicates that a subprocess call returned a nonzero exit status."""
+    #: The exit code of the failed subprocess.
+    code = None
+    #: List of the program and arguments that failed.
+    args = None
+    #: The stdout of the program.
+    stdout = None
+    #: The stderr of the program.
+    stderr = None
+    def __init__(self, code, args, stdout, stderr):
+        self.code = code
+        self.args = args
+        self.stdout = stdout
+        self.stderr = stderr
+    def __str__(self):
+        compact = self.stderr.rstrip().split("\n")[-1]
+        return "%s (exited with %d)\n%s" % (compact, self.code, self.stderr)
