sssh 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. #
  4. # Copyright (C) 2017 Tyler Cipriani
  5. #
  6. # This program is free software; you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation; either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program; if not, write to the Free Software Foundation,
  18. # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
  19. #
  20. # SSSH is a wrapper around SSH that automatically manages multiple
  21. # ssh-agent(1)s each containing only a single ssh key.
  22. import argparse
  23. import errno
  24. import hashlib
  25. import logging
  26. import os
  27. import subprocess
  28. import sys
  29. try:
  30. from paramiko import SSHConfig
  31. except ImportError:
  32. print "[ERROR] sudo apt-get install python-paramiko"
  33. sys.exit(1)
  34. DESCRIPTION = '''
  35. SSSH is a wrapper around SSH that automatically manages multiple ssh-agent(1)s
  36. each containing only a single ssh key.
  37. SSSH accepts the same parameters as ssh(1) - fundamentally SSSH uses execve(2)
  38. to wrap SSH, modifying the environment to ensure that each key in your
  39. ssh_config(5) uses its own ssh-agent.
  40. '''
  41. LOG = None
  42. # Intentionally omit ``-v`` since I want to be able to see debug output
  43. # for this script when passing ``-v`` as well.
  44. SSH_FLAGS = ['-1', '-2', '-4', '-6', '-A', '-a', '-C', '-f', '-g',
  45. '-K', '-k', '-M', '-N', '-n', '-q', '-s', '-T', '-t',
  46. '-V', '-X', '-x', '-Y', '-y']
  47. SSH_ARGS = ['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L',
  48. '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-w', '-W']
  49. class SSHSock():
  50. """
  51. Creates an ssh-agent socket and adds the approriate runtime variables.
  52. """
  53. def __init__(self, key):
  54. self.key = key
  55. def _get_sock_path(self, checksum):
  56. """
  57. Path for SSH socket files
  58. """
  59. sock = os.path.join(
  60. os.getenv("XDG_RUNTIME_DIR"), "{}.sock".format(checksum))
  61. LOG.debug("Sock path is: {}".format(sock))
  62. return sock
  63. def _add_key(self, sock_file):
  64. """
  65. Add key to existing socket
  66. """
  67. env = os.environ.copy()
  68. env['SSH_AUTH_SOCK'] = sock_file
  69. if self.key.check_key_exists(env):
  70. LOG.debug(
  71. "SSH Key {} already in sock".format(self.key.file))
  72. return
  73. LOG.debug(self.key.file)
  74. cmd = ["/usr/bin/ssh-add", self.key.file]
  75. proc = subprocess.Popen(cmd,
  76. stdout=subprocess.PIPE,
  77. stderr=subprocess.STDOUT,
  78. env=env)
  79. (stdout, stderr) = proc.communicate()
  80. if proc.returncode:
  81. raise OSError(
  82. 1,
  83. "Could not add identityfile sock file",
  84. self.key.file
  85. )
  86. def create(self):
  87. """
  88. Checksums the identity file and creates a new socket.
  89. New socket will contain *only that one key*.
  90. """
  91. if self.key.empty:
  92. return
  93. checksum = hashlib.md5()
  94. checksum.update(self.key.file)
  95. sock_file = self._get_sock_path(checksum.hexdigest())
  96. if not os.path.exists(sock_file):
  97. cmd = [
  98. "/usr/bin/ssh-agent",
  99. "-a{}".format(sock_file),
  100. ]
  101. error = subprocess.check_call(cmd)
  102. if error:
  103. raise OSError(1, "Could not create sock file", sock_file)
  104. self._add_key(sock_file)
  105. return sock_file
  106. class SSHKey():
  107. """
  108. Abstract the SSH identity file finding and checksumming.
  109. """
  110. def __init__(self, host):
  111. """
  112. Find the identity file based on hostname
  113. """
  114. config = SSHConfig()
  115. config.parse(open(self._get_ssh_config()))
  116. host_config = config.lookup(host)
  117. id_file = host_config.get("identityfile")
  118. self.id_file = None
  119. self.fingerprint = None
  120. if id_file is not None:
  121. self.id_file = id_file[::-1][0]
  122. LOG.debug("SSH identity file is: {}".format(self.id_file))
  123. def _get_ssh_config(self):
  124. """
  125. Try to find ssh config file at default location
  126. """
  127. default = os.path.join(os.getenv("HOME"), ".ssh", "config")
  128. path = os.getenv("SSH_CONF_PATH", default)
  129. LOG.debug("SSH config path is: {}".format(path))
  130. if not os.path.exists(path) or not os.path.isfile(path):
  131. raise IOError(
  132. errno.ENOENT,
  133. "File not found", path)
  134. return path
  135. def get_fingerprint(self):
  136. """
  137. Return fingerprint of SSH identity file
  138. """
  139. if self.id_file is None:
  140. return None
  141. if self.fingerprint is not None:
  142. return self.fingerprint
  143. cmd = [
  144. '/usr/bin/ssh-keygen',
  145. '-l',
  146. '-f',
  147. self.id_file]
  148. out = subprocess.check_output(cmd)
  149. self.fingerprint = out.strip().split()[1]
  150. LOG.debug("Key fingerprint is: {}".format(self.fingerprint))
  151. return self.fingerprint
  152. def check_key_exists(self, env):
  153. cmd = ["ssh-add", "-l"]
  154. proc = subprocess.Popen(cmd,
  155. stdout=subprocess.PIPE,
  156. stderr=subprocess.STDOUT,
  157. env=env)
  158. out, err = proc.communicate()
  159. return self.get_fingerprint() in out.split()
  160. @property
  161. def empty(self):
  162. return self.id_file is None
  163. @property
  164. def file(self):
  165. return self.id_file
  166. def parse_known_args():
  167. """Parse commandline arguments."""
  168. parser = argparse.ArgumentParser(
  169. usage='sssh [whatever you want to pass to ssh]',
  170. description=DESCRIPTION)
  171. for flagname in SSH_FLAGS:
  172. parser.add_argument(flagname, action='count',
  173. help=argparse.SUPPRESS)
  174. for optionname in SSH_ARGS:
  175. parser.add_argument(optionname, help=argparse.SUPPRESS)
  176. parser.add_argument('-v', action='count', dest="verbose",
  177. help='Increase verbosity of output')
  178. parser.add_argument('hostname', help=argparse.SUPPRESS)
  179. parser.add_argument('command', nargs='?', help=argparse.SUPPRESS)
  180. return parser.parse_known_args()
  181. def setup_logging(args):
  182. """
  183. Setup logging level based on passed args.
  184. """
  185. global LOG
  186. level = logging.INFO
  187. if args.verbose > 0:
  188. level = logging.DEBUG
  189. log_format = '%(asctime)s %(filename)s %(message)s'
  190. logging.basicConfig(level=level, format=log_format)
  191. LOG = logging.getLogger("ssh")
  192. def get_host(args):
  193. """Extract hostname from command line args"""
  194. hostname = args.hostname
  195. # Handle the [user]@[hostname] syntax
  196. if '@' in hostname:
  197. hostname = hostname.split('@')[1]
  198. # If for some whacky reason the hostname has a protocol...
  199. if hostname.startswith('ssh://'):
  200. hostname = hostname[len('ssh://'):]
  201. # Handle a port in the hostname
  202. if ':' in hostname:
  203. hostname = hostname.split(':')[0]
  204. LOG.debug('Hostname is {}'.format(hostname))
  205. return hostname
  206. def run_ssh(args, sock=None):
  207. """
  208. Exec ssh in the environemnt
  209. """
  210. env = {}
  211. if sock is not None:
  212. LOG.info('SSH_AUTH_SOCK={}'.format(sock))
  213. env = os.environ.copy()
  214. env["SSH_AUTH_SOCK"] = sock
  215. ssh = ["/usr/bin/ssh"]
  216. ssh.extend(args)
  217. os.execve("/usr/bin/ssh", ssh, env)
  218. def main(args):
  219. """
  220. Handle the whole deal.
  221. #. Find the host
  222. #. Find the keyfile for the host in the ssh config
  223. #. Create the ssh-agent and socket
  224. #. Exec ssh
  225. """
  226. args, extra = parse_known_args()
  227. setup_logging(args)
  228. host = get_host(args)
  229. sock = SSHSock(SSHKey(host))
  230. run_ssh(sys.argv[1:], sock.create())
  231. if __name__ == "__main__":
  232. sys.exit(main(sys.argv[1:]))