From 61e19337655bc76709ea2c602f60f2d98c4f8961 Mon Sep 17 00:00:00 2001 From: Adam Dodman Date: Fri, 15 Dec 2017 19:00:55 +0000 Subject: [PATCH] Add gopass support --- ssh-ident/config | 69 +++ ssh-ident/scp | 1 + ssh-ident/sftp | 1 + ssh-ident/ssh | 1 + ssh-ident/ssh-ident | 1029 +++++++++++++++++++++++++++++++++++++++++++ zsh/zshrc | 4 + 6 files changed, 1105 insertions(+) create mode 100644 ssh-ident/config create mode 120000 ssh-ident/scp create mode 120000 ssh-ident/sftp create mode 120000 ssh-ident/ssh create mode 100755 ssh-ident/ssh-ident diff --git a/ssh-ident/config b/ssh-ident/config new file mode 100644 index 0000000..fffc1cc --- /dev/null +++ b/ssh-ident/config @@ -0,0 +1,69 @@ +# SSH-Ident +# +# Specifies which identity to use depending on the path I'm running ssh +# from. +# For example: ("mod-xslt", "personal") means that for any path that +# contains the word "mod-xslt", the "personal" identity should be used. +# This is optional - don't include any MATCH_PATH if you don't need it. +#MATCH_PATH = [ +# # (directory pattern, identity) +# (r"mod-xslt", "personal"), +# (r"ssh-ident", "personal"), +# (r"opt/work", "work"), +# (r"opt/private", "secret"), +#] +# +# If any of the ssh arguments have 'cweb' in it, the 'personal' identity +# has to be used. For example: "ssh myhost.cweb.com" will have cweb in +# argv, and the "personal" identity will be used. +# This is optional - don't include any MATCH_ARGV if you don't +# need it. +#MATCH_ARGV = [ +# (r"cweb", "personal"), +# (r"corp", "work"), +#] +# +# Note that if no match is found, the DEFAULT_IDENTITY is used. This is +# generally your loginname, no need to change it. +# This is optional - don't include any DEFAULT_IDENTITY if you don't +# need it. +# DEFAULT_IDENTITY = "foo" + +# This is optional - don't include any SSH_ADD_OPTIONS if you don't +# need it. +#SSH_ADD_OPTIONS = { +# # Regardless, ask for confirmation before using any of the +# # work keys. +# "work": "-c", +# # Forget about secret keys after ten minutes. ssh-ident will +# # automatically ask you your passphrase again if they are needed. +# "secret": "-t 600", +# +#} +# +# This is optional - dont' include any SSH_OPTIONS if you don't +# need it. +# Otherwise, provides options to be passed to 'ssh' for specific +# identities. +#SSH_OPTIONS = { +# # Disable forwarding of the agent, but enable X forwarding, +# # when using the work profile. +# "work": "-Xa", +# +# # Always forward the agent when using the secret identity. +# "secret": "-A", +#} +# +# Options to pass to ssh by default. +# If you don't specify anything, UserRoaming=no is passed, due +# to CVE-2016-0777. Leave it empty to disable this. +SSH_DEFAULT_OPTIONS = "-oUseRoaming=no" + +# Which options to use by default if no match with SSH_ADD_OPTIONS +# was found. Note that ssh-ident hard codes -t 7200 to prevent your +# keys from remaining in memory for too long. +SSH_ADD_DEFAULT_OPTIONS = "-t 900" + +# Output verbosity +# valid values are: LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG +VERBOSITY = LOG_INFO diff --git a/ssh-ident/scp b/ssh-ident/scp new file mode 120000 index 0000000..158bd12 --- /dev/null +++ b/ssh-ident/scp @@ -0,0 +1 @@ +ssh-ident \ No newline at end of file diff --git a/ssh-ident/sftp b/ssh-ident/sftp new file mode 120000 index 0000000..158bd12 --- /dev/null +++ b/ssh-ident/sftp @@ -0,0 +1 @@ +ssh-ident \ No newline at end of file diff --git a/ssh-ident/ssh b/ssh-ident/ssh new file mode 120000 index 0000000..158bd12 --- /dev/null +++ b/ssh-ident/ssh @@ -0,0 +1 @@ +ssh-ident \ No newline at end of file diff --git a/ssh-ident/ssh-ident b/ssh-ident/ssh-ident new file mode 100755 index 0000000..a615b63 --- /dev/null +++ b/ssh-ident/ssh-ident @@ -0,0 +1,1029 @@ +#!/usr/bin/env python +# vim: tabstop=2 shiftwidth=2 expandtab +"""Start and use ssh-agent and load identities as necessary. + +Use this script to start ssh-agents and load ssh keys on demand, +when they are first needed. + +All you have to do is modify your .bashrc to have: + + alias ssh='/path/to/ssh-ident' + +or add a link to ssh-ident from a directory in your PATH, for example: + + ln -s /path/to/ssh-ident ~/bin/ssh + +If you use scp or rsync regularly, you should add a few more lines described +below. + +In any case, ssh-ident: + +- will create an ssh-agent and load the keys you need the first time you + actually need them, once. No matter how many terminals, ssh or login + sessions you have, no matter if your home is shared via NFS. + +- can prepare and use a different agent and different set of keys depending + on the host you are connecting to, or the directory you are using ssh + from. + This allows for isolating keys when using agent forwarding with different + sites (eg, university, work, home, secret evil internet identity, ...). + It also allows to use multiple accounts on sites like github, unfuddle + and gitorious easily. + +- allows to specify different options for each set of keys. For example, you + can provide a -t 60 to keep keys loaded for at most 60 seconds. Or -c to + always ask for confirmation before using a key. + + +Installation +============ + +All you need to run ssh-ident is a standard installation of python >= 2.6, +python > 3 is supported. + +If your system has wget and are impatient to use it, you can install +ssh-ident with two simple commands: + + mkdir -p ~/bin; wget -O ~/bin/ssh goo.gl/MoJuKB; chmod 0755 ~/bin/ssh + + echo 'export PATH=~/bin:$PATH' >> ~/.bashrc + +Logout, login, and done. SSH should now invoke ssh-ident instead of the +standard ssh. + + +Alternatives +============ + +In .bashrc, I have: + + alias ssh=/home/ccontavalli/scripts/ssh-ident + +all I have to do now is logout, login and then: + + $ ssh somewhere + +ssh-ident will be called instead of ssh, and it will: +- check if an agent is running. If not, it will start one. +- try to load all the keys in ~/.ssh, if not loaded. + +If I now ssh again, or somewhere else, ssh-ident will reuse the same agent +and the same keys, if valid. + + +About scp, rsync, and friends +============================= + +scp, rsync, and most similar tools internally invoke ssh. If you don't tell +them to use ssh-ident instead, key loading won't work. There are a few ways +to solve the problem: + +1) Rename 'ssh-ident' to 'ssh' or create a symlink 'ssh' pointing to + ssh-ident in a directory in your PATH before /usr/bin or /bin, similarly + to what was described previously. For example, add to your .bashrc: + + export PATH="~/bin:$PATH" + + And run: + + ln -s /path/to/ssh-ident ~/bin/ssh + + Make sure `echo $PATH` shows '~/bin' *before* '/usr/bin' or '/bin'. You + can verify this is working as expected with `which ssh`, which should + show ~/bin/ssh. + + This works for rsync and git, among others, but not for scp and sftp, as + these do not look for ssh in your PATH but use a hard-coded path to the + binary. + + If you want to use ssh-ident with scp or sftp, you can simply create + symlinks for them as well: + + ln -s /path/to/ssh-ident ~/bin/scp + ln -s /path/to/ssh-ident ~/bin/sftp + +2) Add a few more aliases in your .bashrc file, for example: + + alias scp='BINARY_SSH=scp /path/to/ssh-ident' + alias rsync='BINARY_SSH=rsync /path/to/ssh-ident' + ... + + The first alias will make the 'scp' command invoke 'ssh-ident' instead, + but tell 'ssh-ident' to invoke 'scp' instead of the plain 'ssh' command + after loading the necessary agents and keys. + + Note that aliases don't work from scripts - if you have any script that + you expect to use with ssh-ident, you may prefer method 1), or you will + need to update the script accordingly. + +3) Use command specific methods to force them to use ssh-ident instead of + ssh, for example: + + rsync -e '/path/to/ssh-ident' ... + scp -S '/path/to/ssh-ident' ... + +4) Replace the real ssh on the system with ssh-ident, and set the + BINARY_SSH configuration parameter to the original value. + + On Debian based system, you can make this change in a way that + will survive automated upgrades and audits by running: + + dpkg-divert --divert /usr/bin/ssh.ssh-ident --rename /usr/bin/ssh + + After which, you will need to use: + + BINARY_SSH="/usr/bin/ssh.ssh-ident" + + +Config file with multiple identities +==================================== + +To have multiple identities, all I have to do is: + +1) create a ~/.ssh-ident file. In this file, I need to tell ssh-ident which + identities to use and when. The file should look something like: + + # Specifies which identity to use depending on the path I'm running ssh + # from. + # For example: ("mod-xslt", "personal") means that for any path that + # contains the word "mod-xslt", the "personal" identity should be used. + # This is optional - don't include any MATCH_PATH if you don't need it. + MATCH_PATH = [ + # (directory pattern, identity) + (r"mod-xslt", "personal"), + (r"ssh-ident", "personal"), + (r"opt/work", "work"), + (r"opt/private", "secret"), + ] + + # If any of the ssh arguments have 'cweb' in it, the 'personal' identity + # has to be used. For example: "ssh myhost.cweb.com" will have cweb in + # argv, and the "personal" identity will be used. + # This is optional - don't include any MATCH_ARGV if you don't + # need it. + MATCH_ARGV = [ + (r"cweb", "personal"), + (r"corp", "work"), + ] + + # Note that if no match is found, the DEFAULT_IDENTITY is used. This is + # generally your loginname, no need to change it. + # This is optional - don't include any DEFAULT_IDENTITY if you don't + # need it. + # DEFAULT_IDENTITY = "foo" + + # This is optional - don't include any SSH_ADD_OPTIONS if you don't + # need it. + SSH_ADD_OPTIONS = { + # Regardless, ask for confirmation before using any of the + # work keys. + "work": "-c", + # Forget about secret keys after ten minutes. ssh-ident will + # automatically ask you your passphrase again if they are needed. + "secret": "-t 600", + } + + # This is optional - dont' include any SSH_OPTIONS if you don't + # need it. + # Otherwise, provides options to be passed to 'ssh' for specific + # identities. + SSH_OPTIONS = { + # Disable forwarding of the agent, but enable X forwarding, + # when using the work profile. + "work": "-Xa", + + # Always forward the agent when using the secret identity. + "secret": "-A", + } + + # Options to pass to ssh by default. + # If you don't specify anything, UserRoaming=no is passed, due + # to CVE-2016-0777. Leave it empty to disable this. + SSH_DEFAULT_OPTIONS = "-oUseRoaming=no" + + # Which options to use by default if no match with SSH_ADD_OPTIONS + # was found. Note that ssh-ident hard codes -t 7200 to prevent your + # keys from remaining in memory for too long. + SSH_ADD_DEFAULT_OPTIONS = "-t 7200" + + # Output verbosity + # valid values are: LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG + VERBOSITY = LOG_INFO + +2) Create the directory where all the identities and agents + will be kept: + + $ mkdir -p ~/.ssh/identities; chmod u=rwX,go= -R ~/.ssh + +3) Create a directory for each identity, for example: + + $ mkdir -p ~/.ssh/identities/personal + $ mkdir -p ~/.ssh/identities/work + $ mkdir -p ~/.ssh/identities/secret + +4) Generate (or copy) keys for those identities: + + # Default keys are for my personal account + $ cp ~/.ssh/id_rsa* ~/.ssh/identities/personal + + # Generate keys to be used for work only, rsa + $ ssh-keygen -t rsa -b 4096 -f ~/.ssh/identities/work/id_rsa + + ... + + +Now if I run: + + $ ssh corp.mywemployer.com + +ssh-ident will be invoked instead, and: + + 1) check ssh argv, determine that the "work" identity has to be used. + 2) look in ~/.ssh/agents, for a "work" agent loaded. If there is no + agent, it will prepare one. + 3) look in ~/.ssh/identities/work/* for a list of keys to load for this + identity. It will try to load any key that is not already loaded in + the agent. + 4) finally run ssh with the environment setup such that it will have + access only to the agent for the identity work, and the corresponding + keys. + +Note that ssh-ident needs to access both your private and public keys. Note +also that it identifies public keys by the .pub extension. All files in your +identities subdirectories will be considered keys. + +If you want to only load keys that have "key" in the name, you can add +to your .ssh-ident: + + PATTERN_KEYS = "key" + +The default is: + + PATTERN_KEYS = r"/(id_.*|identity.*|ssh[0-9]-.*)" + +You can also redefine: + + DIR_IDENTITIES = "$HOME/.ssh/identities" + DIR_AGENTS = "$HOME/.ssh/agents" + +To point somewhere else if you so desire. + + +BUILDING A DEBIAN PACKAGE +========================= + +If you need to use ssh-ident on a debian / ubuntu / or any other +derivate, you can now build debian packages. + + 1. Make sure you have devscripts installed: + + sudo apt-get install devscripts debhelper + + 2. Download ssh-ident in a directory of your choice (ssh-ident) + + git clone https://github.com/ccontavalli/ssh-ident.git ssh-ident + + 3. Build the .deb package: + + cd ssh-ident && debuild -us -uc + + 4. Profit: + + cd ..; dpkg -i ssh-ident*.deb + + +CREDITS +======= + +- Carlo Contavalli, http://www.github.com/ccontavalli, main author. +- Hubert depesz Lubaczewski, http://www.github.com/despez, support + for using environment variables for configuration. +- Flip Hess, http://www.github.com/fliphess, support for building + a .deb out of ssh-ident. +- Terrel Shumway, https://www.github.com/scholarly, port to python3. +- black2754, https://www.github.com/black2754, vim modeline, support + for verbosity settings, and BatchMode passing. +- Michael Heap, https://www.github.com/mheap, support for per + identities config files. +- Carl Drougge, https://www.github.com/drougge, CVE-2016-0777 fix, + fix for per user config files, and use /bin/env instead of python + path. +""" + +from __future__ import print_function + +import collections +import distutils.spawn +import errno +import fcntl +import getpass +import glob +import os +import re +import socket +import subprocess +import sys +import termios +import textwrap + +# constants so noone has deal with cryptic numbers +LOG_CONSTANTS = {"LOG_ERROR": 1, "LOG_WARN": 2, "LOG_INFO": 3, "LOG_DEBUG": 4} +# load them directly into the global scope, for easy use +# not exactly pretty... +globals().update(LOG_CONSTANTS) + + +def ShouldPrint(config, loglevel): + """Returns true if a message by the specified loglevel should be printed.""" + # determine the current output verbosity + verbosity = config.Get("VERBOSITY") + + # verbosity may be a string, e.g. 'LOG_INFO' + # this happens when it comes from the OS env, but also if quotes are + # used in the config file + if isinstance(verbosity, str): + if verbosity in LOG_CONSTANTS: + # resolve the loglevel, e.g. 'LOG_INFO' -> 3 + verbosity = LOG_CONSTANTS[verbosity] + else: + # the string may also be a number, e.g. '3' -> 3 + verbosity = int(verbosity) + if loglevel <= verbosity: + return True + return False + + +class SshIdentPrint(object): + """Wrapper around python's print function.""" + + def __init__(self, config): + """ + config: object implementing the Config interface, allows access to + the user configuration parameters. + + Attributes: + config: same as above. + python_print: python's print function (hopefully) + + Parameters: + SSH_BATCH_MODE: used to check if messages should be printed or not + VERBOSITY: used to check if messages should be printed or not + """ + self.config = config + self.python_print = print + + def write(self, *args, **kwargs): + """Passes all parameters to python's print, + unless output is disabled by the configuration. + The interface is compatible with python's print, but supports the + optional parameter 'loglevel' in addition.""" + if self.config.Get("SSH_BATCH_MODE"): + # no output in BatchMode + return + + # determine the loglevel of this message + if "loglevel" in kwargs: + loglevel = kwargs["loglevel"] + # make sure not to pass the loglevel parameter to print + del kwargs["loglevel"] + else: + # if the loglevel is not given, default to INFO + loglevel = LOG_INFO + + if ShouldPrint(self.config, loglevel): + self.python_print(*args, **kwargs) + + __call__ = write + + +class Config(object): + """Holds and loads users configurations.""" + + defaults = { + # Where to find the per-user configuration. + "FILE_USER_CONFIG": "$HOME/.ssh-ident", + + # Where to find all the identities for the user. + "DIR_IDENTITIES": "$HOME/.ssh/identities", + # Where to keep the information about each running agent. + "DIR_AGENTS": "$HOME/.ssh/agents", + + # How to identify key files in the identities directory. + "PATTERN_KEYS": r"/(id_.*|identity.*|ssh[0-9]-.*)", + + # How to identify ssh config files. + "PATTERN_CONFIG": r"/config$", + + # Dictionary with identity as a key, automatically adds + # the specified options to the ssh command run. + "SSH_OPTIONS": {}, + # Additional options to append to ssh by default. + "SSH_DEFAULT_OPTIONS": "-oUseRoaming=no", + + # Complete path of full ssh binary to use. If not set, ssh-ident will + # try to find the correct binary in PATH. + "BINARY_SSH": None, + "BINARY_DIR": None, + + # Which identity to use by default if we cannot tell from + # the current working directory and/or argv. + "DEFAULT_IDENTITY": "$USER", + + # Those should really be overridden by the user. Look + # at the documentation for more details. + "MATCH_PATH": [], + "MATCH_ARGV": [], + + # Dictionary with identity as a key, allows to specify + # per identity options when using ssh-add. + "SSH_ADD_OPTIONS": {}, + # ssh-add default options. By default, don't keep a key longer + # than 2 hours. + "SSH_ADD_DEFAULT_OPTIONS": "-t 7200", + + # Like BatchMode in ssh, see man 5 ssh_config. + # In BatchMode ssh-ident will not print any output and not ask for + # any passphrases. + "SSH_BATCH_MODE": False, + + # Output verbosity + # valid values are: LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG + # use 0 to disable ALL output (not recommended!) + "VERBOSITY": LOG_INFO, + } + + def __init__(self): + self.values = {} + + def Load(self): + """Load configurations from the default user file.""" + path = self.Get("FILE_USER_CONFIG") + variables = {} + try: + exec(compile(open(path).read(), path, 'exec'), LOG_CONSTANTS, variables) + except IOError: + return self + self.values = variables + return self + + @staticmethod + def Expand(value): + """Expand environment variables or ~ in string parameters.""" + if isinstance(value, str): + return os.path.expanduser(os.path.expandvars(value)) + return value + + def Get(self, parameter): + """Returns the value of a parameter, or causes the script to exit.""" + if parameter in os.environ: + return self.Expand(os.environ[parameter]) + if parameter in self.values: + return self.Expand(self.values[parameter]) + if parameter in self.defaults: + return self.Expand(self.defaults[parameter]) + + print( + "Parameter '{0}' needs to be defined in " + "config file or defaults".format(parameter), file=sys.stderr, + loglevel=LOG_ERROR) + sys.exit(2) + + def Set(self, parameter, value): + """Sets configuration option parameter to value.""" + self.values[parameter] = value + +def FindIdentityInList(elements, identities): + """Matches a list of identities to a list of elements. + + Args: + elements: iterable of strings, arbitrary strings to match on. + identities: iterable of (string, string), with first string + being a regular expression, the second string being an identity. + + Returns: + The identity specified in identities for the first regular expression + matching the first element in elements. + """ + for element in elements: + for regex, identity in identities: + if re.search(regex, element): + return identity + return None + +def FindIdentity(argv, config): + """Returns the identity to use based on current directory or argv. + + Args: + argv: iterable of string, argv passed to this program. + config: instance of an object implementing the same interface as + the Config class. + + Returns: + string, the name of the identity to use. + """ + paths = set([os.getcwd(), os.path.abspath(os.getcwd()), os.path.normpath(os.getcwd())]) + return ( + FindIdentityInList(argv, config.Get("MATCH_ARGV")) or + FindIdentityInList(paths, config.Get("MATCH_PATH")) or + config.Get("DEFAULT_IDENTITY")) + +def FindKeys(identity, config): + """Finds all the private and public keys associated with an identity. + + Args: + identity: string, name of the identity to load strings of. + config: object implementing the Config interface, providing configurations + for the user. + + Returns: + dict, {"key name": {"pub": "/path/to/public/key", "priv": + "/path/to/private/key"}}, for each key found, the path of the public + key and private key. The key name is just a string representing the + key. Note that for a given key, it is not guaranteed that both the + public and private key will be found. + The return value is affected by DIR_IDENTITIES and PATTERN_KEYS + configuration parameters. + """ + directories = [os.path.join(config.Get("DIR_IDENTITIES"), identity)] + if identity == getpass.getuser(): + directories.append(os.path.expanduser("~/.ssh")) + + pattern = re.compile(config.Get("PATTERN_KEYS")) + found = collections.defaultdict(dict) + for directory in directories: + try: + keyfiles = os.listdir(directory) + except OSError as e: + if e.errno == errno.ENOENT: + continue + raise + + for key in keyfiles: + key = os.path.join(directory, key) + if not os.path.isfile(key): + continue + if not pattern.search(key): + continue + + kinds = ( + ("private", "priv"), + ("public", "pub"), + (".pub", "pub"), + ("", "priv"), + ) + for match, kind in kinds: + if match in key: + found[key.replace(match, "")][kind] = key + + if not found: + print("Warning: no keys found for identity {0} in:".format(identity), + file=sys.stderr, + loglevel=LOG_WARN) + print(directories, file=sys.stderr, loglevel=LOG_WARN) + + return found + + +def FindSSHConfig(identity, config): + """Finds a config file if there's one associated with an identity + + Args: + identity: string, name of the identity to load strings of. + config: object implementing the Config interface, providing configurations + for the user. + + Returns: + string, the configuration file to use + """ + directories = [os.path.join(config.Get("DIR_IDENTITIES"), identity)] + + pattern = re.compile(config.Get("PATTERN_CONFIG")) + sshconfigs = collections.defaultdict(dict) + for directory in directories: + try: + sshconfigs = os.listdir(directory) + except OSError as e: + if e.errno == errno.ENOENT: + continue + raise + + for sshconfig in sshconfigs: + sshconfig = os.path.join(directory, sshconfig) + if os.path.isfile(sshconfig) and pattern.search(sshconfig): + return sshconfig + + return False + + +def GetSessionTty(): + """Returns a file descriptor for the session TTY, or None. + + In *nix systems, each process is tied to one session. Each + session can be tied (or not) to a terminal, "/dev/tty". + + Additionally, when a command is run, its stdin or stdout can + be any file descriptor, including one that represent a tty. + + So for example: + + ./test.sh < /dev/null > /dev/null + + will have stdin and stdout tied to /dev/null - but does not + tell us anything about the session having a /dev/tty associated + or not. + + For example, running + + ssh -t user@remotehost './test.sh < /dev/null > /dev/null' + + have a tty associated, while the same command without -t will not. + + When ssh is invoked by tools like git or rsyn, its stdin and stdout + is often tied to a file descriptor which is not a terminal, has + the tool wants to provide the input and process the output. + + ssh-ident internally has to invoke ssh-add, which needs to know if + it has any terminal it can use at all. + + This function returns an open file if the session has an usable terminal, + None otherwise. + """ + try: + fd = open("/dev/tty", "r") + fcntl.ioctl(fd, termios.TIOCGPGRP, " ") + except IOError: + return None + return fd + + +class AgentManager(object): + """Manages the ssh-agent for one identity.""" + + def __init__(self, identity, sshconfig, config): + """Initializes an AgentManager object. + + Args: + identity: string, identity the ssh-agent managed by this instance of + an AgentManager will control. + config: object implementing the Config interface, allows access to + the user configuration parameters. + + Attributes: + identity: same as above. + config: same as above. + agents_path: directory where the config of all agents is kept. + agent_file: the config of the agent corresponding to this identity. + + Parameters: + DIR_AGENTS: used to compute agents_path. + BINARY_SSH: path to the ssh binary. + """ + self.identity = identity + self.config = config + self.ssh_config = sshconfig + self.agents_path = os.path.abspath(config.Get("DIR_AGENTS")) + self.agent_file = self.GetAgentFile(self.agents_path, self.identity) + + def LoadUnloadedKeys(self, keys): + """Loads all the keys specified that are not loaded. + + Args: + keys: dict as returned by FindKeys. + """ + toload = self.FindUnloadedKeys(keys) + if toload: + print("Loading keys:\n {0}".format( "\n ".join(toload)), + file=sys.stderr, loglevel=LOG_INFO) + self.LoadKeyFiles(toload) + else: + print("All keys already loaded", file=sys.stderr, loglevel=LOG_INFO) + + def FindUnloadedKeys(self, keys): + """Determines which keys have not been loaded yet. + + Args: + keys: dict as returned by FindKeys. + + Returns: + iterable of strings, paths to private key files to load. + """ + loaded = set(self.GetLoadedKeys()) + toload = set() + for key, config in keys.items(): + if "pub" not in config: + continue + if "priv" not in config: + continue + + fingerprint = self.GetPublicKeyFingerprint(config["pub"]) + if fingerprint in loaded: + continue + + toload.add(config["priv"]) + return toload + + def LoadKeyFiles(self, keys): + """Load all specified keys. + + Args: + keys: iterable of strings, each string a path to a key to load. + """ + keys = " ".join(keys) + options = self.config.Get("SSH_ADD_OPTIONS").get( + self.identity, self.config.Get("SSH_ADD_DEFAULT_OPTIONS")) + console = GetSessionTty() + self.RunShellCommandInAgent( + self.agent_file, "ssh-add {0} {1}".format(options, keys), + stdout=console, stdin=console) + + def GetLoadedKeys(self): + """Returns an iterable of strings, each the fingerprint of a loaded key.""" + retval, stdout = self.RunShellCommandInAgent(self.agent_file, "ssh-add -l") + if retval != 0: + return [] + + fingerprints = [] + for line in stdout.decode("utf-8").split("\n"): + try: + _, fingerprint, _ = line.split(" ", 2) + fingerprints.append(fingerprint) + except ValueError: + continue + return fingerprints + + @staticmethod + def GetPublicKeyFingerprint(key): + """Returns the fingerprint of a public key as a string.""" + retval, stdout = AgentManager.RunShellCommand( + "ssh-keygen -l -f {0} |tr -s ' '".format(key)) + if retval: + return None + + try: + _, fingerprint, _ = stdout.decode("utf-8").split(" ", 2) + except ValueError: + return None + return fingerprint + + @staticmethod + def GetAgentFile(path, identity): + """Returns the path to an agent config file. + + Args: + path: string, the path where agent config files are kept. + identity: string, identity for which to load the agent. + + Returns: + string, path to the agent file. + """ + # Create the paths, if they do not exist yet. + try: + os.makedirs(path, 0o700) + except OSError as e: + if e.errno != errno.EEXIST: + raise OSError( + "Cannot create agents directory, try manually with " + "'mkdir -p {0}'".format(path)) + + # Use the hostname as part of the path just in case this is on NFS. + agentfile = os.path.join( + path, "agent-{0}-{1}".format(identity, socket.gethostname())) + if os.access(agentfile, os.R_OK) and AgentManager.IsAgentFileValid(agentfile): + print("Agent for identity {0} ready".format(identity), file=sys.stderr, + loglevel=LOG_DEBUG) + return agentfile + + print("Preparing new agent for identity {0}".format(identity), file=sys.stderr, + loglevel=LOG_DEBUG) + retval = subprocess.call( + ["/usr/bin/env", "-i", "/bin/sh", "-c", "ssh-agent > {0}".format(agentfile)]) + return agentfile + + @staticmethod + def IsAgentFileValid(agentfile): + """Returns true if the specified agentfile refers to a running agent.""" + retval, output = AgentManager.RunShellCommandInAgent( + agentfile, "ssh-add -l >/dev/null 2>/dev/null") + if retval & 0xff not in [0, 1]: + print("Agent in {0} not running".format(agentfile), file=sys.stderr, + loglevel=LOG_DEBUG) + return False + return True + + @staticmethod + def RunShellCommand(command): + """Runs a shell command, returns (status, stdout), (int, string).""" + command = ["/bin/sh", "-c", command] + process = subprocess.Popen(command, stdout=subprocess.PIPE) + stdout, stderr = process.communicate() + return process.wait(), stdout + + @staticmethod + def RunShellCommandInAgent(agentfile, command, stdin=None, stdout=subprocess.PIPE): + """Runs a shell command with an agent configured in the environment.""" + command = ["/usr/bin/env", "-i", "/bin/sh", "-c", + ". {0} >/dev/null 2>/dev/null; {1}".format(agentfile, command)] + process = subprocess.Popen(command, stdin=stdin, stdout=stdout) + stdout, stderr = process.communicate() + return process.wait(), stdout + + @staticmethod + def EscapeShellArguments(argv): + """Escapes all arguments to the shell, returns a string.""" + escaped = [] + for arg in argv: + escaped.append("'{0}'".format(arg.replace("'", "'\"'\"'"))) + return " ".join(escaped) + + def GetShellArgs(self): + """Returns the flags to be passed to the shell to run a command.""" + shell_args = "-c" + if ShouldPrint(self.config, LOG_DEBUG): + shell_args = "-xc" + return shell_args + + def RunSSH(self, argv): + """Execs ssh with the specified arguments.""" + additional_flags = self.config.Get("SSH_OPTIONS").get( + self.identity, self.config.Get("SSH_DEFAULT_OPTIONS")) + if (self.ssh_config): + additional_flags += " -F {0}".format(self.ssh_config) + + command = [ + "/bin/sh", self.GetShellArgs(), + ". {0} >/dev/null 2>/dev/null; exec {1} {2} {3}".format( + self.agent_file, self.config.Get("BINARY_SSH"), + additional_flags, self.EscapeShellArguments(argv))] + os.execv("/bin/sh", command) + +def AutodetectBinary(argv, config): + """Detects the correct binary to run and sets BINARY_SSH accordingly, + if it is not already set.""" + # If BINARY_SSH is set by the user, respect that and do nothing. + if config.Get("BINARY_SSH"): + print("Will run '{0}' as ssh binary - set by user via BINARY_SSH" + .format(config.Get("BINARY_SSH")), loglevel=LOG_DEBUG) + return + + # If BINARY_DIR is set, look for the binary in this directory. + runtime_name = argv[0] + if config.Get("BINARY_DIR"): + binary_name = os.path.basename(runtime_name) + binary_path = os.path.join(config.Get("BINARY_DIR"), binary_name) + if not os.path.isfile(binary_path) or not os.access(binary_path, os.X_OK): + binary_path = os.path.join(config.Get("BINARY_DIR"), "ssh") + + config.Set("BINARY_SSH", binary_path) + print("Will run '{0}' as ssh binary - detected based on BINARY_DIR" + .format(config.Get("BINARY_SSH")), loglevel=LOG_DEBUG) + return + + # argv[0] could be pretty much anything the caller decides to set + # it to: an absolute path, a relative path (common in older systems), + # or even something entirely unrelated. + # + # Similar is true for __file__, which might even represent a location + # that is entirely unrelated to how ssh-ident was found. + # + # Consider also that there might be symlinks / hard links involved. + # + # The logic here is pretty straightforward: + # - Try to eliminate the path of ssh-ident from PATH. + # - Search for a binary with the same name of ssh-ident to run. + # + # If this fails, we may end up in some sort of loop, where ssh-ident + # tries to run itself. This should normally be detected later on, + # where the code checks for the next binary to run. + # + # Note also that users may not be relying on having ssh-ident in the + # PATH at all - for example, with "rsync -e '/path/to/ssh-ident' ..." + binary_name = os.path.basename(runtime_name) + ssh_ident_path = "" + if not os.path.dirname(runtime_name): + message = textwrap.dedent("""\ + argv[0] ("{0}") is a relative path. This means that ssh-ident does + not know its own directory, and can't exclude it from searching it + in $PATH: + + PATH="{1}" + + This may result in a loop, with 'ssh-ident' trying to run itself. + It is recommended that you set BINARY_SSH, BINARY_DIR, or run + ssh-ident differently to prevent this problem.""") + print(message.format(runtime_name, os.environ['PATH']), + loglevel=LOG_INFO) + else: + ssh_ident_path = os.path.abspath(os.path.dirname(runtime_name)) + + # Remove the path containing the ssh-ident symlink (or whatever) from + # the search path, so we do not cause an infinite loop. + # Note that: + # - paths in PATH may be not-normalized, example: "/usr/bin/../foo", + # or "/opt/scripts///". Normalize them before comparison. + # - paths in PATH may be repeated multiple times. We have to exclude + # all instances of the ssh-ident path. + normalized_path = [ + os.path.normpath(p) for p in os.environ['PATH'].split(os.pathsep)] + search_path = os.pathsep.join([ + p for p in normalized_path if p != ssh_ident_path]) + + # Find an executable with the desired name. + binary_path = distutils.spawn.find_executable(binary_name, search_path) + if not binary_path: + # Nothing found. Try to find something named 'ssh'. + binary_path = distutils.spawn.find_executable('ssh') + + if binary_path: + config.Set("BINARY_SSH", binary_path) + print("Will run '{0}' as ssh binary - detected from argv[0] and $PATH" + .format(config.Get("BINARY_SSH")), loglevel=LOG_DEBUG) + else: + message = textwrap.dedent("""\ + ssh-ident was invoked in place of the binary {0} (determined from argv[0]). + Neither this binary nor 'ssh' could be found in $PATH. + + PATH="{1}" + + You need to adjust your setup for ssh-ident to work: consider setting + BINARY_SSH or BINARY_DIR in your config, or running ssh-ident some + other way.""") + print(message.format(argv[0], os.environ['PATH']), loglevel=LOG_ERROR) + sys.exit(255) + +def ParseCommandLine(argv, config): + """Parses the command line parameters in argv + and modifies config accordingly.""" + # This function may need a lot of refactoring if it is ever used for more + # than checking for BatchMode for OpenSSH... + binary = os.path.basename(config.Get("BINARY_SSH")) + if binary == 'ssh' or binary == 'scp': + # OpenSSH accepts -o Options as well as -oOption, + # so let's convert argv to the latter form first + i = iter(argv) + argv = [p+next(i, '') if p == '-o' else p for p in i] + # OpenSSH accepts 'Option=yes' and 'Option yes', 'true' instead of 'yes' + # and treats everything case-insensitive + # if an option is given multiple times, + # OpenSSH considers the first occurrence only + re_batchmode = re.compile(r"-oBatchMode[= ](yes|true)", re.IGNORECASE) + re_nobatchmode = re.compile(r"-oBatchMode[= ](no|false)", re.IGNORECASE) + for p in argv: + if re.match(re_batchmode, p): + config.Set("SSH_BATCH_MODE", True) + break + elif re.match(re_nobatchmode, p): + config.Set("SSH_BATCH_MODE", False) + break + +def main(argv): + # Replace stdout and stderr with /dev/tty, so we don't mess up with scripts + # that use ssh in case we error out or similar. + try: + sys.stdout = open("/dev/tty", "w") + sys.stderr = open("/dev/tty", "w") + except IOError: + pass + + config = Config().Load() + # overwrite python's print function with the wrapper SshIdentPrint + global print + print = SshIdentPrint(config) + + AutodetectBinary(argv, config) + # Check that BINARY_SSH is not ssh-ident. + # This can happen if the user sets a binary name only (e.g. 'scp') and a + # symlink with the same name was set up. + # Note that this relies on argv[0] being set sensibly by the caller, + # which is not always the case. argv[0] may also just have the binary + # name if found in a path. + binary_path = os.path.realpath( + distutils.spawn.find_executable(config.Get("BINARY_SSH"))) + ssh_ident_path = os.path.realpath( + distutils.spawn.find_executable(argv[0])) + if binary_path == ssh_ident_path: + message = textwrap.dedent("""\ + ssh-ident found '{0}' as the next command to run. + Based on argv[0] ({1}), it seems like this will create a + loop. + + Please use BINARY_SSH, BINARY_DIR, or change the way + ssh-ident is invoked (eg, a different argv[0]) to make + it work correctly.""") + print(message.format(config.Get("BINARY_SSH"), argv[0]), loglevel=LOG_ERROR) + sys.exit(255) + ParseCommandLine(argv, config) + identity = FindIdentity(argv, config) + keys = FindKeys(identity, config) + sshconfig = FindSSHConfig(identity, config) + agent = AgentManager(identity, sshconfig, config) + + if not config.Get("SSH_BATCH_MODE"): + # do not load keys in BatchMode + agent.LoadUnloadedKeys(keys) + return agent.RunSSH(argv[1:]) + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except KeyboardInterrupt: + print("Goodbye", file=sys.stderr, loglevel=LOG_DEBUG) diff --git a/zsh/zshrc b/zsh/zshrc index c1113c9..1e60693 100644 --- a/zsh/zshrc +++ b/zsh/zshrc @@ -33,6 +33,10 @@ alias shred='shred -uzn 5' alias beet="eval $(docker-machine env DockerOther | sed -n 's/export\s*//gp' | xargs ) docker exec -ti beets beet" alias beet-restart="eval $(docker-machine env DockerOther | sed -n 's/export\s*//gp' | xargs ) docker restart beets" +# If Gopass exists, use it instead of Pass +which gopass &>/dev/null && alias pass='gopass' +source <(gopass completion zsh) + #VirtualEnv export WORKON_HOME=$HOME/.virtualenvs # optional export PROJECT_HOME=$HOME/Sync/Programming/Python # optional