diff --git a/.gitignore b/.gitignore index 7567c09..73a5b42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ *~ .*.sw? ssh-identc +*.pyc +.tox +ssh_ident.egg-info/ +build +dist diff --git a/scripts/ssh-ident-completion.bash b/scripts/ssh-ident-completion.bash new file mode 100644 index 0000000..33bcf75 --- /dev/null +++ b/scripts/ssh-ident-completion.bash @@ -0,0 +1,93 @@ +_SSH_COMPLETION_FILE=${SSH_IDENT_SSH_COMPLETION:-/usr/share/bash-completion/completions/ssh} +source $_SSH_COMPLETION_FILE + +# Save function named in $1 as name in $2 +save_function() { + local ORIG_FUNC=$(declare -f $1) + local NEWNAME_FUNC="$2${ORIG_FUNC#$1}" + eval "$NEWNAME_FUNC" +} + +save_function _ssh_configfile __ssh_configfile_orig + +_ssh_ident_configfile_override() +{ + if [ ! -z ${__ssh_ident_config+x} ]; then + configfile=$__ssh_ident_config + fi + + set -- "${words[@]}" + while [[ $# -gt 0 ]]; do + if [[ $1 == -F* ]]; then + if [[ ${#1} -gt 2 ]]; then + configfile="$(dequote "${1:2}")" + else + shift + [[ $1 ]] && configfile="$(dequote "$1")" + fi + break + fi + shift + done +} + +_ssh_ident_ssh() +{ + # Set the ssh config path if available + if [[ "${SSH_IDENT_CONFIG}" != "" ]]; then + __ssh_ident_config=${SSH_IDENT_CONFIG} + fi + + # Override function _ssh_configfile in _SSH_COMPLETION_FILE + save_function _ssh_ident_configfile_override _ssh_configfile + _ssh $@ + # Restore _ssh_configfile function + save_function __ssh_configfile_orig _ssh_configfile + + unset __ssh_ident_config + + return 0 +} && shopt -u hostcomplete && complete -F _ssh_ident_ssh ssh slogin autossh + + +_ssh_ident_scp() +{ + # Set the ssh config path if available + if [[ "${SSH_IDENT_CONFIG}" != "" ]]; then + __ssh_ident_config=${SSH_IDENT_CONFIG} + fi + + # Override function _ssh_configfile in _SSH_COMPLETION_FILE + save_function _ssh_ident_configfile_override _ssh_configfile + _scp $@ + # Restore _ssh_configfile function + save_function __ssh_configfile_orig _ssh_configfile + + unset __ssh_ident_config + + return 0 +} && complete -F _ssh_ident_scp scp + + +# scp completion with _ssh_ident_scp doesn't work properly due to the extra call +# 'set -- "${words[@]}"' in function _scp() in +# /usr/share/bash-completion/completions/ssh + +#_ssh_ident_scp() +#{ +# # Set the ssh config path if available +# if [[ "${SSH_IDENT_CONFIG}" != "" ]]; then +# conf="-F$SSH_IDENT_CONFIG" +# conf_len=${#conf} +# set -- "${@:1:1}" "$conf" "${@:2}" +# COMP_WORDS=("$@") +# COMP_CWORD=$((COMP_CWORD+1)) +# COMP_LINE=$(printf " %s" "$@") +# COMP_LINE=${COMP_LINE:1} +# COMP_POINT=$((COMP_POINT+$conf_len+1)) +# fi +# +# _scp $@ +# +# return 0 +#} && complete -F _ssh_ident_scp scp diff --git a/scripts/ssh_ident.sh b/scripts/ssh_ident.sh new file mode 100644 index 0000000..0ee5aca --- /dev/null +++ b/scripts/ssh_ident.sh @@ -0,0 +1,180 @@ +# Bash functions for ssh-ident + +#Flag values returned from ssh_ident_cli +__ssh_ident_cli_flag_set_shell_env=1 +__ssh_ident_cli_flag_unset_shell_env=2 +__ssh_ident_cli_flag_ssh_identity=4 +__ssh_ident_cli_flag_ssh_config=8 +__ssh_ident_cli_flag_enable_prompt=16 +__ssh_ident_cli_flag_disable_prompt=32 +__ssh_ident_cli_flag_define_bash_functions=64 +__ssh_ident_cli_flag_undefine_bash_functions=128 +__ssh_ident_cli_flag_print_output=256 +__ssh_ident_cli_flag_verbose=512 +__ssh_ident_cli_flag_disable_shell_agent=1024 +__ssh_ident_cli_flag_enable_shell_agent=2048 + + +define_ssh_ident_bash_funcs () { + # SSH_IDENT_EXEC_SCRIPT must be global to be available when the functions are executed + SSH_IDENT_EXEC_SCRIPT=`which ssh_ident_exec` + export GIT_SSH=$SSH_IDENT_EXEC_SCRIPT + rsync() { BINARY_SSH=ssh command rsync -e $SSH_IDENT_EXEC_SCRIPT "$@"; } + sftp() { BINARY_SSH=ssh command sftp -S $SSH_IDENT_EXEC_SCRIPT "$@"; } + scp() { BINARY_SSH=ssh command scp -S $SSH_IDENT_EXEC_SCRIPT "$@"; } + ssh() { BINARY_SSH=ssh command $SSH_IDENT_EXEC_SCRIPT "$@"; } +} + +undefine_ssh_ident_bash_funcs () { + unset -f rsync + unset -f sftp + unset -f scp + unset -f ssh + unset GIT_SSH +} + +__ssh_ident_split_output() { + local cli_action_str + read -d '' -r cli_action_str cli_stdout <<< "$1" + # Convert to int + cli_action=$((cli_action_str + 0)) + # Remove trailing newline + cli_stdout="${cli_stdout%"${cli_stdout##*[![:space:]]}"}" +} + +# Called using PROMPT_COMMAND variable +__ssh_ident_update_ssh_config_var() { + local cli_action + local cli_output + local cli_stdout + cli_output=`ssh_ident_cli --action-code --config $__SSH_IDENT_PROMPT_ID` + __ssh_ident_split_output "$cli_output" + + if [[ $(($cli_action & $__ssh_ident_cli_flag_ssh_config)) -gt 0 ]] ; then + SSH_IDENT_CONFIG=$cli_stdout + fi +} + +# Called using PROMPT_COMMAND variable +__ssh_ident_update_prompt_id() { + # If SSH_IDENT is set for the shell + if [[ -n "$SSH_IDENT" ]]; then + if [[ "$SSH_IDENT" != "$__SSH_IDENT_PROMPT_ID" ]]; then + __SSH_IDENT_PROMPT_ID=$SSH_IDENT + __ssh_ident_update_ssh_config_var + unset __SSH_IDENT_PWD + fi + return + fi + + # Only update __SSH_IDENT_PROMPT_ID if PWD has changed + if [[ "$__SSH_IDENT_PWD" != "$PWD" ]]; then + __SSH_IDENT_PWD=$PWD + local cli_result + cli_result=`ssh_ident_cli -iq` + __SSH_IDENT_PROMPT_ID=$cli_result + __ssh_ident_update_ssh_config_var + fi +} + +# Activate the ssh-ident shell prompt +ssh_ident_activate() { + ssh_ident -a $1 +} + +# Activate the ssh-ident shell prompt +ssh_ident_dectivate() { + ssh_ident -d +} + +# Main entry point to ssh-ident. Calls ssh_ident_cli and acts according to the response +ssh_ident() { + local cli_output + local cli_action + local cli_stdout + local cli_verbose=0 + cli_output=`ssh_ident_cli --action-code $@` + __ssh_ident_split_output "$cli_output" + + if [[ $(($cli_action & $__ssh_ident_cli_flag_verbose)) -gt 0 ]] ; then + cli_verbose=1 + echo "Shell action flags: $cli_action" + fi + + # Set/Unset shell identity env variable + if [[ $(($cli_action & $__ssh_ident_cli_flag_set_shell_env)) -gt 0 ]] ; then + export SSH_IDENT=$cli_stdout + elif [[ $(($cli_action & $__ssh_ident_cli_flag_unset_shell_env)) -gt 0 ]] ; then + unset SSH_IDENT + fi + + if [[ $(($cli_action & $__ssh_ident_cli_flag_ssh_identity)) -gt 0 ]] ; then + if [[ $cli_verbose -eq 1 ]] ; then + echo "Setting shell identity: $SSH_IDENT" + fi + fi + + # Enable/Disable ssh-ident shell prompt + if [[ $(($cli_action & $__ssh_ident_cli_flag_enable_prompt)) -gt 0 ]] ; then + # If prompt already contains __SSH_IDENT_PROMPT_ID, ignore + if [[ ! "$PS1" == *__SSH_IDENT_PROMPT_ID* ]]; then + __SSH_IDENT_OLD_PROMPT=$PS1 + PS1="(ssh:\${__SSH_IDENT_PROMPT_ID}) $PS1" + SSH_IDENT_PROMPT_COMMAND=__ssh_ident_update_prompt_id + # Add to PROMPT_COMMAND only if not already added + if [[ ! "$PROMPT_COMMAND" == *SSH_IDENT_PROMPT_COMMAND* ]]; then + PROMPT_COMMAND=${PROMPT_COMMAND:+$PROMPT_COMMAND; }'$SSH_IDENT_PROMPT_COMMAND' + fi + fi + elif [[ $(($cli_action & $__ssh_ident_cli_flag_disable_prompt)) -gt 0 ]] ; then + unset SSH_IDENT_PROMPT_COMMAND + unset __SSH_IDENT_PROMPT_ID + unset __SSH_IDENT_PWD + unset -f rsync + if [ ! -z "${__SSH_IDENT_OLD_PROMPT}" ] ; then + PS1=$__SSH_IDENT_OLD_PROMPT + unset __SSH_IDENT_OLD_PROMPT + fi + fi + + # Define/Undefine bash function overrides for ssh/scp/sftp/rsync + if [[ $(($cli_action & $__ssh_ident_cli_flag_define_bash_functions)) -gt 0 ]] ; then + define_ssh_ident_bash_funcs + elif [[ $(($cli_action & $__ssh_ident_cli_flag_undefine_bash_functions)) -gt 0 ]] ; then + undefine_ssh_ident_bash_funcs + fi + + if [[ $(($cli_action & $__ssh_ident_cli_flag_disable_shell_agent)) -gt 0 ]] ; then + echo "ssh-agent disabled for shell" + export SSH_IDENT_SHELL_AGENT_DISABLED=1 + fi + + if [[ $(($cli_action & $__ssh_ident_cli_flag_enable_shell_agent)) -gt 0 ]] ; then + echo "ssh-agent enabled for shell" + unset SSH_IDENT_SHELL_AGENT_DISABLED + fi + + if [[ $(($cli_action & $__ssh_ident_cli_flag_print_output)) -gt 0 ]] ; then + echo "$cli_stdout" + fi +} + +# Source the ssh-ident bash completion +source_bash_completion() { + local ssh_ident_bash_completion_script=ssh-ident-completion.bash + local script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + local bash_completion_script_path=`which $ssh_ident_bash_completion_script` + + # If not found, check the dir containing this script + if [[ -z "$bash_completion_script_path" ]]; then + bash_completion_script_path=$script_dir/$ssh_ident_bash_completion_script + fi + + if [[ -f $bash_completion_script_path ]]; then + source $bash_completion_script_path + else + echo "Bash completion script $ssh_ident_bash_completion_script not found!" + fi +} + +source_bash_completion diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..fcaab00 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[isort] +known_standard_library = future_builtins +known_third_party = jinja2,requests,nose +#known_first_party = system +order_by_type = true +line_length = 100 +not_skip = __init__.py +skip = .tox,vendor,build diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a5c4651 --- /dev/null +++ b/setup.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import print_function + +import os + +from setuptools import find_packages, setup +from setuptools.command.develop import develop + +# Importing __version__ from ssh_ident fails for some reason, so import directly +from ssh_ident.ssh_ident import VERSION + + +class SshIdentDevelop(develop): + + user_options = develop.user_options + [ + ("symlinks", "s", "Create script symlinks"), + ] + boolean_options = develop.boolean_options + ['symlinks'] + + def initialize_options(self): + self.symlinks = False + develop.initialize_options(self) + + def run(self): + develop.run(self) + + if not self.symlinks: + return + + print("Creating symlinks for scripts") + for script in self.distribution.scripts or []: + fname = os.path.basename(script) + script_abs_path = os.path.abspath(script) + dest_file = os.path.join(self.script_dir, fname) + if os.path.isfile(dest_file): + os.remove(dest_file) + print("%s -> %s" % (dest_file, script_abs_path)) + os.symlink(script_abs_path, dest_file) + + +setup(name='ssh-ident', + version=VERSION, + description="Start and use ssh-agent and load identities as necessary.", + long_description="Start and use ssh-agent and load identities as necessary.", + url='https://github.com/ccontavalli/ssh-ident', + license="BSD", + packages=find_packages(), + namespace_packages=['ssh_ident'], + entry_points={'console_scripts': + ['ssh_ident_exec = ssh_ident.ssh_ident:main', + 'ssh_ident_cli = ssh_ident.ssh_ident_cli:main' + ] + }, + scripts=[ + 'scripts/ssh-ident-completion.bash', + 'scripts/ssh_ident.sh', + ], + cmdclass={'develop': SshIdentDevelop}, + zip_safe=False, + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: POSIX' + ]) diff --git a/ssh_ident/__init__.py b/ssh_ident/__init__.py new file mode 100644 index 0000000..35ade88 --- /dev/null +++ b/ssh_ident/__init__.py @@ -0,0 +1,7 @@ +# this is a namespace package +import pkg_resources +from ssh_ident import VERSION + +pkg_resources.declare_namespace(__name__) + +__version__ = VERSION diff --git a/ssh-ident b/ssh_ident/ssh_ident.py similarity index 91% rename from ssh-ident rename to ssh_ident/ssh_ident.py index 320aa6b..f02e52c 100755 --- a/ssh-ident +++ b/ssh_ident/ssh_ident.py @@ -122,14 +122,14 @@ 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 +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" @@ -317,14 +317,19 @@ import errno import fcntl import getpass -import glob import os import re import socket import subprocess import sys -import termios import textwrap +import termios + +from utils import enum + +VERSION = "1.0.0" + +IDENTITY_TYPE = enum("SSH_IDENT", "MATCH_ARGV", "MATCH_PATH", "DEFAULT") # constants so noone has deal with cryptic numbers LOG_CONSTANTS = {"LOG_ERROR": 1, "LOG_WARN": 2, "LOG_INFO": 3, "LOG_DEBUG": 4} @@ -396,6 +401,10 @@ def write(self, *args, **kwargs): __call__ = write +class BadConfigKey(Exception): + pass + + class Config(object): """Holds and loads users configurations.""" @@ -434,6 +443,9 @@ class Config(object): "MATCH_PATH": [], "MATCH_ARGV": [], + "SSH_IDENT_PROMPT": True, + "SSH_IDENT_BASH_FUNCTIONS": True, + # Dictionary with identity as a key, allows to specify # per identity options when using ssh-add. "SSH_ADD_OPTIONS": {}, @@ -452,6 +464,17 @@ class Config(object): "VERBOSITY": LOG_INFO, } + overridable_by_env = ["FILE_USER_CONFIG", + "DIR_IDENTITIES", + "DIR_AGENTS", + "PATTERN_KEYS", + "PATTERN_CONFIG", + "SSH_DEFAULT_OPTIONS", + #"BINARY_SSH", + #"BINARY_DIR", + "DEFAULT_IDENTITY", + "SSH_ADD_DEFAULT_OPTIONS"] + def __init__(self): self.values = {} @@ -475,7 +498,7 @@ def Expand(value): def Get(self, parameter): """Returns the value of a parameter, or causes the script to exit.""" - if parameter in os.environ: + if parameter in Config.overridable_by_env and parameter in os.environ: return self.Expand(os.environ[parameter]) if parameter in self.values: return self.Expand(self.values[parameter]) @@ -488,9 +511,12 @@ def Get(self, parameter): loglevel=LOG_ERROR) sys.exit(2) - def Set(self, parameter, value): + def Set(self, key, value): """Sets configuration option parameter to value.""" - self.values[parameter] = value + if key not in self.defaults: + raise BadConfigKey("Invalid config key '%s" % key) + self.values[key] = value + def FindIdentityInList(elements, identities): """Matches a list of identities to a list of elements. @@ -507,8 +533,9 @@ def FindIdentityInList(elements, identities): for element in elements: for regex, identity in identities: if re.search(regex, element): - return identity - return None + return identity, regex + return None, None + def FindIdentity(argv, config): """Returns the identity to use based on current directory or argv. @@ -522,10 +549,21 @@ def FindIdentity(argv, config): 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")) + + ident = os.environ.get('SSH_IDENT', None) + if ident: + return ident, IDENTITY_TYPE.SSH_IDENT, None + + ident, regex = FindIdentityInList(argv, config.Get("MATCH_ARGV")) + if ident: + return ident, IDENTITY_TYPE.MATCH_ARGV, regex + + ident, regex = FindIdentityInList(paths, config.Get("MATCH_PATH")) + if ident: + return ident, IDENTITY_TYPE.MATCH_PATH, regex + + return config.Get("DEFAULT_IDENTITY"), IDENTITY_TYPE.DEFAULT, None + def FindKeys(identity, config): """Finds all the private and public keys associated with an identity. @@ -577,8 +615,8 @@ def FindKeys(identity, config): if not found: print("Warning: no keys found for identity {0} in:".format(identity), - file=sys.stderr, - loglevel=LOG_WARN) + file=sys.stderr, + loglevel=LOG_WARN) print(directories, file=sys.stderr, loglevel=LOG_WARN) return found @@ -632,7 +670,7 @@ def GetSessionTty(): tell us anything about the session having a /dev/tty associated or not. - For example, running + For example, running ssh -t user@remotehost './test.sh < /dev/null > /dev/null' @@ -677,6 +715,8 @@ def __init__(self, identity, sshconfig, config): Parameters: DIR_AGENTS: used to compute agents_path. BINARY_SSH: path to the ssh binary. + EXTRA_SSH_OPTIONS: add extra ssh options from the shell + """ self.identity = identity self.config = config @@ -692,11 +732,11 @@ def LoadUnloadedKeys(self, keys): """ toload = self.FindUnloadedKeys(keys) if toload: - print("Loading keys:\n {0}".format( "\n ".join(toload)), - file=sys.stderr, loglevel=LOG_INFO) + 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) + print("All keys already loaded", file=sys.stderr, loglevel=LOG_DEBUG) def FindUnloadedKeys(self, keys): """Determines which keys have not been loaded yet. @@ -790,12 +830,12 @@ def GetAgentFile(path, identity): 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) + loglevel=LOG_DEBUG) return agentfile print("Preparing new agent for identity {0}".format(identity), file=sys.stderr, - loglevel=LOG_DEBUG) - retval = subprocess.call( + loglevel=LOG_DEBUG) + subprocess.call( ["/usr/bin/env", "-i", "/bin/sh", "-c", "ssh-agent > {0}".format(agentfile)]) return agentfile @@ -806,7 +846,7 @@ def IsAgentFileValid(agentfile): 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) + loglevel=LOG_DEBUG) return False return True @@ -846,16 +886,31 @@ 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): + + # When this is set, to not load agent configs for current identity + SSH_IDENT_SHELL_AGENT_DISABLED = os.environ.get("SSH_IDENT_SHELL_AGENT_DISABLED", None) + + # Allow adding custom ssh options by setting EXTRA_SSH_OPTIONS environment variable + if "EXTRA_SSH_OPTIONS" in os.environ: + additional_flags += (" %s " % os.environ["EXTRA_SSH_OPTIONS"]) + + if self.ssh_config: additional_flags += " -F {0}".format(self.ssh_config) + def get_load_agent_command(agent_file=None): + if agent_file: + return ". {0} >/dev/null 2>/dev/null;".format(agent_file) + return "" + 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))] + "/bin/sh", self.GetShellArgs(), + "{0} exec {1} {2} {3}".format( + get_load_agent_command(self.agent_file if SSH_IDENT_SHELL_AGENT_DISABLED is None else None), + 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.""" @@ -890,7 +945,7 @@ def AutodetectBinary(argv, config): # 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. @@ -942,7 +997,7 @@ def AutodetectBinary(argv, config): 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}" + 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 @@ -950,6 +1005,7 @@ def AutodetectBinary(argv, config): 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.""" @@ -960,7 +1016,7 @@ def ParseCommandLine(argv, config): # 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] + 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, @@ -975,18 +1031,20 @@ def ParseCommandLine(argv, config): config.Set("SSH_BATCH_MODE", False) break -def main(argv): + +def main(): + global print + argv = sys.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 + except IOError as err: + print("Error replacing stdout/stderr with /dev/tty: %s" % err, file=sys.stderr) config = Config().Load() # overwrite python's print function with the wrapper SshIdentPrint - global print print = SshIdentPrint(config) AutodetectBinary(argv, config) @@ -996,23 +1054,21 @@ def main(argv): # 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])) + 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("""\ + 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. - + 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) + identity, id_type, match = FindIdentity(argv, config) keys = FindKeys(identity, config) sshconfig = FindSSHConfig(identity, config) agent = AgentManager(identity, sshconfig, config) @@ -1022,8 +1078,13 @@ def main(argv): agent.LoadUnloadedKeys(keys) return agent.RunSSH(argv[1:]) + if __name__ == "__main__": try: - sys.exit(main(sys.argv)) + sys.exit(main()) except KeyboardInterrupt: print("Goodbye", file=sys.stderr, loglevel=LOG_DEBUG) + +# Local Variables: +# eval: (enable-guess-style) +# End: diff --git a/ssh_ident/ssh_ident_cli.py b/ssh_ident/ssh_ident_cli.py new file mode 100755 index 0000000..00d23ce --- /dev/null +++ b/ssh_ident/ssh_ident_cli.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python +# vim: tabstop=2 shiftwidth=2 expandtab +from __future__ import print_function + +import argparse +import os +import sys +from os.path import isdir, join + +from ssh_ident import IDENTITY_TYPE, Config, FindIdentity, FindSSHConfig +from utils import enum + +ACTION_FLAGS = enum(SET_SHELL_ENV=1, + UNSET_SHELL_ENV=2, + SSH_IDENTITY=4, + SSH_CONFIG=8, + ENABLE_PROMPT=16, + DISABLE_PROMPT=32, + DEFINE_BASH_FUNCTIONS=64, + UNDEFINE_BASH_FUNCTIONS=128, + PRINT_OUTPUT=256, + VERBOSE=512, + DISABLE_SHELL_AGENT=1024, + ENABLE_SHELL_AGENT=2048) + + +def get_identities(config): + identities_path = config.Get("DIR_IDENTITIES") + if not os.path.isdir(identities_path): + return [] + return [f for f in os.listdir(identities_path) if isdir(join(identities_path, f))] + + +def main(): + """ + + exits with sys.exit return value indicating an action to be performed + defined by ACTION_FLAGS enum. + + Exit code 0 means to print the output directly + """ + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("-q", "--quiet", action="store_true", help="Be quieter") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") + parser.add_argument('-h', '--help', action='store_true', dest='help') + parser.add_argument('--action-code', action='store_true', + help='Prefix output with an int containing flags as defined by ACTION_FLAGS') + group = parser.add_mutually_exclusive_group() + group.add_argument("-l", "--list", action="store_true", help="List identities") + group.add_argument("-i", "--identity", action="store_true", help="Show current identity") + group.add_argument("-a", "--activate", nargs='?', const=True, + help="Activate ssh-ident with default config settings. " + "If a user is provided, only activate if the current user matches the given user.") + group.add_argument("-d", "--deactivate", action="store_true", help="Dectivate ssh-ident shell") + group.add_argument("-da", "--disable-agent", action="store_true", + help="Disable ssh-ident ssh-agent for current shell") + group.add_argument("-ea", "--enable-agent", action="store_true", + help="Enable ssh-ident ssh-agent for current shell") + group.add_argument("-c", "--create", metavar='', help="Create a new identity") + group.add_argument("-s", "--shell", metavar='', nargs='?', const='default-ssh-id', + help="Set identity for the shell") + group.add_argument("-u", "--unset-shell", action="store_true", help="Unset identity for the shell") + group.add_argument("-p", "--prompt", action="store_true", help="Enable prompt") + group.add_argument("-r", "--remove-prompt", action="store_true", help="Remove prompt indicator") + group.add_argument("--config", metavar='', help="Get config for a specified identity.") + + args = parser.parse_args() + config = Config().Load() + import StringIO + stdoutput = StringIO.StringIO() + + exit_val = 0 + print_help = False + + if args.help: + print_help = True + elif args.list: + exit_val |= ACTION_FLAGS.PRINT_OUTPUT + idents = get_identities(config) + print("Identities:", file=stdoutput) + for i in sorted(idents): + print("- %s" % i, file=stdoutput) + elif args.identity: + exit_val |= ACTION_FLAGS.PRINT_OUTPUT + idents = get_identities(config) + identity, id_type, match = FindIdentity(sys.argv, config) + if identity not in idents: + print("Bad identity set by %s (%s). Should be one of '%s'" % + (IDENTITY_TYPE.kstr(id_type), identity, ", ".join(idents)), + file=stdoutput) + else: + if args.quiet: + print(identity, file=stdoutput) + else: + print("%s (set by %s%s)" % (identity, IDENTITY_TYPE.kstr(id_type), " [%s]" % match if match else ""), + file=stdoutput) + elif args.create: + identities_path = config.Get("DIR_IDENTITIES") + id_path = os.path.join(identities_path, args.create) + os.mkdir(id_path) + open(os.path.join(id_path, "config"), 'a').close() + print("Created identity '%s': %s" % (args.create, id_path), file=stdoutput) + exit_val |= ACTION_FLAGS.PRINT_OUTPUT + elif args.shell: + if args.shell != 'default-ssh-id' and args.shell not in get_identities(config): + print("Bad identity '%s'" % (args.shell), file=stdoutput) + exit_val |= ACTION_FLAGS.PRINT_OUTPUT + else: + exit_val |= ACTION_FLAGS.SET_SHELL_ENV | ACTION_FLAGS.SSH_IDENTITY + if args.shell == 'default-ssh-id': + args.shell = config.Get("DEFAULT_IDENTITY") + print(args.shell, file=stdoutput, end='') + elif args.unset_shell: + exit_val |= ACTION_FLAGS.UNSET_SHELL_ENV + elif args.prompt: + exit_val |= ACTION_FLAGS.ENABLE_PROMPT + elif args.remove_prompt: + exit_val |= ACTION_FLAGS.DISABLE_PROMPT + elif args.activate: + # If a user is provided, should match current user running the shell. + # if ssh-ident is activated in a users .bashrc, sourcing that .bashrc + # file from another user will fail if that user doesn't also have + # ssh-ident configured. By explicitly providing a user, ssh-ident will + # only be activated if the specified user is the one loading the .bashrc file. + activate = args.activate == True or args.activate == os.environ.get('USER') + if activate: + idents = get_identities(config) + identity, id_type, match = FindIdentity(sys.argv, config) + if identity in idents: + exit_val |= ACTION_FLAGS.SSH_IDENTITY + print(identity, file=stdoutput) + if config.Get("SSH_IDENT_PROMPT"): + exit_val |= ACTION_FLAGS.ENABLE_PROMPT + if config.Get("SSH_IDENT_BASH_FUNCTIONS"): + exit_val |= ACTION_FLAGS.DEFINE_BASH_FUNCTIONS + elif args.deactivate: + exit_val |= ACTION_FLAGS.UNSET_SHELL_ENV + if config.Get("SSH_IDENT_PROMPT"): + exit_val |= ACTION_FLAGS.DISABLE_PROMPT + if config.Get("SSH_IDENT_BASH_FUNCTIONS"): + exit_val |= ACTION_FLAGS.UNDEFINE_BASH_FUNCTIONS + elif args.disable_agent: + exit_val |= ACTION_FLAGS.DISABLE_SHELL_AGENT + elif args.enable_agent: + exit_val |= ACTION_FLAGS.ENABLE_SHELL_AGENT + elif args.config: + sshconfig = FindSSHConfig(args.config, config) + if sshconfig: + if args.action_code: + exit_val = ACTION_FLAGS.SSH_CONFIG + print(sshconfig, file=stdoutput) + exit_val |= ACTION_FLAGS.PRINT_OUTPUT + else: + print_help = True + + if print_help: + exit_val |= ACTION_FLAGS.PRINT_OUTPUT + h = parser.format_help() + print(h, file=stdoutput) + + if args.verbose: + exit_val |= ACTION_FLAGS.VERBOSE + + if args.action_code: + print("%d %s" % (exit_val, stdoutput.getvalue().strip())) + else: + print(stdoutput.getvalue().strip()) + + +if __name__ == "__main__": + main() diff --git a/ssh_ident/utils.py b/ssh_ident/utils.py new file mode 100644 index 0000000..d2c373c --- /dev/null +++ b/ssh_ident/utils.py @@ -0,0 +1,44 @@ +def enum(*sequential, **named): + items = {} + kwargs = {} + for k, v in named.iteritems(): + if k[0].isupper(): + items[k] = v + else: + kwargs[k] = v + + seq = bool(kwargs.get('sequential')) + if seq: + enums = dict(zip(sequential, range(1, len(sequential) + 1)), **items) + else: + enums = dict(zip(sequential, map(lambda x: 2 ** (x - 1) if x > 0 else 0, range(len(sequential)))), **items) + + reverse = dict((value, key) for key, value in enums.iteritems()) + + def not_implemented(): + raise NotImplementedError() + + def normalise(self, k): + k = k.upper() if isinstance(k, basestring) else k + return k if k in self and k in enums.values() else enums[k] + + def key_str(self, key_val): + for k, v in enums.items(): + if v == key_val: + return k + return None + meta = type('EnumMeta', (type,), { + "__contains__": lambda self, v: v in enums.keys() or v in enums.values(), + "__len__": lambda self: len(sequential), + "__getitem__": lambda self, k: normalise(self, k), + "__setattr__": lambda self, k, v: not_implemented(), + "__setitem__": lambda self, k, v: not_implemented(), + "__iter__": lambda self: iter(reverse.items()), + "items": lambda self: reverse.items(), + "kstr": lambda self, k: key_str(self, k), + }) + + d = {} + d.update(enums) + + return meta('Enum', (object,), d) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1cbd109 --- /dev/null +++ b/tox.ini @@ -0,0 +1,44 @@ +[tox] +skipsdist = true +skip_install = true +envlist = flake8, isort + +[flake8] +max-line-length = 120 +builtins = _,_n,__request__ +exclude = .git,.tox,dist,build,vendor +# E111: indentation is not a multiple of four +# E114: indentation is not a multiple of four (comment) +# E124: closing bracket does not match visual indentation +# E402: module level import not at top of file +#N802: function name should be lowercase +ignore = E402,E124,E111,E114,N802 + +[testenv] +sitepackages = False +whitelist_externals = echo + +[testenv:flake8] +# Disble site packages to avoid using system flake8 which uses hardcoded python path which imports the wrong libraries. +sitepackages = False +deps = + flake8==3.3.0 + pep8-naming==0.4.1 +commands = + flake8 --version + flake8 + +[testenv:isort] +deps = isort==4.2.5 +commands = + python -c 'import isort; print(isort.__version__)' + isort --check-only --diff + +[testenv:pylint] +# Disble site packages to avoid using system flake8 which uses hardcoded python path which imports the wrong libraries. +sitepackages = False +deps = + pylint +commands = + pylint --version + pylint -r n -d I .