├── .gitignore ├── LICENSE ├── README ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules ├── source │ └── format ├── ssh-ident.docs └── ssh-ident.install └── ssh-ident /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .*.sw? 3 | ssh-identc 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012,2013 Carlo Contavalli (ccontavalli@gmail.com). 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY Carlo Contavalli ''AS IS'' AND ANY EXPRESS OR 15 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 16 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 17 | EVENT SHALL Carlo Contavalli OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 18 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 23 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | The views and conclusions contained in the software and documentation are 26 | those of the authors and should not be interpreted as representing official 27 | policies, either expressed or implied, of Carlo Contavalli. 28 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Help on module ssh-ident: 2 | 3 | NAME 4 | ssh-ident - Start and use ssh-agent and load identities as necessary. 5 | 6 | FILE 7 | /opt/projects/ssh-ident.git/ssh-ident 8 | 9 | DESCRIPTION 10 | Use this script to start ssh-agents and load ssh keys on demand, 11 | when they are first needed. 12 | 13 | All you have to do is modify your .bashrc to have: 14 | 15 | alias ssh='/path/to/ssh-ident' 16 | 17 | or add a link to ssh-ident from a directory in your PATH, for example: 18 | 19 | ln -s /path/to/ssh-ident ~/bin/ssh 20 | 21 | If you use scp or rsync regularly, you should add a few more lines described 22 | below. 23 | 24 | In any case, ssh-ident: 25 | 26 | - will create an ssh-agent and load the keys you need the first time you 27 | actually need them, once. No matter how many terminals, ssh or login 28 | sessions you have, no matter if your home is shared via NFS. 29 | 30 | - can prepare and use a different agent and different set of keys depending 31 | on the host you are connecting to, or the directory you are using ssh 32 | from. 33 | This allows for isolating keys when using agent forwarding with different 34 | sites (eg, university, work, home, secret evil internet identity, ...). 35 | It also allows to use multiple accounts on sites like github, unfuddle 36 | and gitorious easily. 37 | 38 | - allows to specify different options for each set of keys. For example, you 39 | can provide a -t 60 to keep keys loaded for at most 60 seconds. Or -c to 40 | always ask for confirmation before using a key. 41 | 42 | 43 | Installation 44 | ============ 45 | 46 | All you need to run ssh-ident is a standard installation of python >= 2.6, 47 | python > 3 is supported. 48 | 49 | If your system has wget and are impatient to use it, you can install 50 | ssh-ident with two simple commands: 51 | 52 | mkdir -p ~/bin; wget -O ~/bin/ssh goo.gl/MoJuKB; chmod 0755 ~/bin/ssh 53 | 54 | echo 'export PATH=~/bin:$PATH' >> ~/.bashrc 55 | 56 | Logout, login, and done. SSH should now invoke ssh-ident instead of the 57 | standard ssh. 58 | 59 | 60 | Alternatives 61 | ============ 62 | 63 | In .bashrc, I have: 64 | 65 | alias ssh=/home/ccontavalli/scripts/ssh-ident 66 | 67 | all I have to do now is logout, login and then: 68 | 69 | $ ssh somewhere 70 | 71 | ssh-ident will be called instead of ssh, and it will: 72 | - check if an agent is running. If not, it will start one. 73 | - try to load all the keys in ~/.ssh, if not loaded. 74 | 75 | If I now ssh again, or somewhere else, ssh-ident will reuse the same agent 76 | and the same keys, if valid. 77 | 78 | 79 | About scp, rsync, and friends 80 | ============================= 81 | 82 | scp, rsync, and most similar tools internally invoke ssh. If you don't tell 83 | them to use ssh-ident instead, key loading won't work. There are a few ways 84 | to solve the problem: 85 | 86 | 1) Rename 'ssh-ident' to 'ssh' or create a symlink 'ssh' pointing to 87 | ssh-ident in a directory in your PATH before /usr/bin or /bin, similarly 88 | to what was described previously. For example, add to your .bashrc: 89 | 90 | export PATH="~/bin:$PATH" 91 | 92 | And run: 93 | 94 | ln -s /path/to/ssh-ident ~/bin/ssh 95 | 96 | Make sure `echo $PATH` shows '~/bin' *before* '/usr/bin' or '/bin'. You 97 | can verify this is working as expected with `which ssh`, which should 98 | show ~/bin/ssh. 99 | 100 | This works for rsync and git, among others, but not for scp and sftp, as 101 | these do not look for ssh in your PATH but use a hard-coded path to the 102 | binary. 103 | 104 | If you want to use ssh-ident with scp or sftp, you can simply create 105 | symlinks for them as well: 106 | 107 | ln -s /path/to/ssh-ident ~/bin/scp 108 | ln -s /path/to/ssh-ident ~/bin/sftp 109 | 110 | 2) Add a few more aliases in your .bashrc file, for example: 111 | 112 | alias scp='BINARY_SSH=scp /path/to/ssh-ident' 113 | alias rsync='BINARY_SSH=rsync /path/to/ssh-ident' 114 | ... 115 | 116 | The first alias will make the 'scp' command invoke 'ssh-ident' instead, 117 | but tell 'ssh-ident' to invoke 'scp' instead of the plain 'ssh' command 118 | after loading the necessary agents and keys. 119 | 120 | Note that aliases don't work from scripts - if you have any script that 121 | you expect to use with ssh-ident, you may prefer method 1), or you will 122 | need to update the script accordingly. 123 | 124 | 3) Use command specific methods to force them to use ssh-ident instead of 125 | ssh, for example: 126 | 127 | rsync -e '/path/to/ssh-ident' ... 128 | scp -S '/path/to/ssh-ident' ... 129 | 130 | 4) Replace the real ssh on the system with ssh-ident, and set the 131 | BINARY_SSH configuration parameter to the original value. 132 | 133 | On Debian based system, you can make this change in a way that 134 | will survive automated upgrades and audits by running: 135 | 136 | dpkg-divert --divert /usr/bin/ssh.ssh-ident --rename /usr/bin/ssh 137 | 138 | After which, you will need to use: 139 | 140 | BINARY_SSH="/usr/bin/ssh.ssh-ident" 141 | 142 | 143 | Config file with multiple identities 144 | ==================================== 145 | 146 | To have multiple identities, all I have to do is: 147 | 148 | 1) create a ~/.ssh-ident file. In this file, I need to tell ssh-ident which 149 | identities to use and when. The file should look something like: 150 | 151 | # Specifies which identity to use depending on the path I'm running ssh 152 | # from. 153 | # For example: ("mod-xslt", "personal") means that for any path that 154 | # contains the word "mod-xslt", the "personal" identity should be used. 155 | # This is optional - don't include any MATCH_PATH if you don't need it. 156 | MATCH_PATH = [ 157 | # (directory pattern, identity) 158 | (r"mod-xslt", "personal"), 159 | (r"ssh-ident", "personal"), 160 | (r"opt/work", "work"), 161 | (r"opt/private", "secret"), 162 | ] 163 | 164 | # If any of the ssh arguments have 'cweb' in it, the 'personal' identity 165 | # has to be used. For example: "ssh myhost.cweb.com" will have cweb in 166 | # argv, and the "personal" identity will be used. 167 | # This is optional - don't include any MATCH_ARGV if you don't 168 | # need it. 169 | MATCH_ARGV = [ 170 | (r"cweb", "personal"), 171 | (r"corp", "work"), 172 | ] 173 | 174 | # Note that if no match is found, the DEFAULT_IDENTITY is used. This is 175 | # generally your loginname, no need to change it. 176 | # This is optional - don't include any DEFAULT_IDENTITY if you don't 177 | # need it. 178 | # DEFAULT_IDENTITY = "foo" 179 | 180 | # This is optional - don't include any SSH_ADD_OPTIONS if you don't 181 | # need it. 182 | SSH_ADD_OPTIONS = { 183 | # Regardless, ask for confirmation before using any of the 184 | # work keys. 185 | "work": "-c", 186 | # Forget about secret keys after ten minutes. ssh-ident will 187 | # automatically ask you your passphrase again if they are needed. 188 | "secret": "-t 600", 189 | } 190 | 191 | # This is optional - dont' include any SSH_OPTIONS if you don't 192 | # need it. 193 | # Otherwise, provides options to be passed to 'ssh' for specific 194 | # identities. 195 | SSH_OPTIONS = { 196 | # Disable forwarding of the agent, but enable X forwarding, 197 | # when using the work profile. 198 | "work": "-Xa", 199 | 200 | # Always forward the agent when using the secret identity. 201 | "secret": "-A", 202 | } 203 | 204 | # Options to pass to ssh by default. 205 | # If you don't specify anything, UserRoaming=no is passed, due 206 | # to CVE-2016-0777. Leave it empty to disable this. 207 | SSH_DEFAULT_OPTIONS = "-oUseRoaming=no" 208 | 209 | # Which options to use by default if no match with SSH_ADD_OPTIONS 210 | # was found. Note that ssh-ident hard codes -t 7200 to prevent your 211 | # keys from remaining in memory for too long. 212 | SSH_ADD_DEFAULT_OPTIONS = "-t 7200" 213 | 214 | # Output verbosity 215 | # valid values are: LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG 216 | VERBOSITY = LOG_INFO 217 | 218 | 2) Create the directory where all the identities and agents 219 | will be kept: 220 | 221 | $ mkdir -p ~/.ssh/identities; chmod u=rwX,go= -R ~/.ssh 222 | 223 | 3) Create a directory for each identity, for example: 224 | 225 | $ mkdir -p ~/.ssh/identities/personal 226 | $ mkdir -p ~/.ssh/identities/work 227 | $ mkdir -p ~/.ssh/identities/secret 228 | 229 | 4) Generate (or copy) keys for those identities: 230 | 231 | # Default keys are for my personal account 232 | $ cp ~/.ssh/id_rsa* ~/.ssh/identities/personal 233 | 234 | # Generate keys to be used for work only, rsa 235 | $ ssh-keygen -t rsa -b 4096 -f ~/.ssh/identities/work/id_rsa 236 | 237 | ... 238 | 239 | 240 | Now if I run: 241 | 242 | $ ssh corp.mywemployer.com 243 | 244 | ssh-ident will be invoked instead, and: 245 | 246 | 1) check ssh argv, determine that the "work" identity has to be used. 247 | 2) look in ~/.ssh/agents, for a "work" agent loaded. If there is no 248 | agent, it will prepare one. 249 | 3) look in ~/.ssh/identities/work/* for a list of keys to load for this 250 | identity. It will try to load any key that is not already loaded in 251 | the agent. 252 | 4) finally run ssh with the environment setup such that it will have 253 | access only to the agent for the identity work, and the corresponding 254 | keys. 255 | 256 | Note that ssh-ident needs to access both your private and public keys. Note 257 | also that it identifies public keys by the .pub extension. All files in your 258 | identities subdirectories will be considered keys. 259 | 260 | If you want to only load keys that have "key" in the name, you can add 261 | to your .ssh-ident: 262 | 263 | PATTERN_KEYS = "key" 264 | 265 | The default is: 266 | 267 | PATTERN_KEYS = r"/(id_.*|identity.*|ssh[0-9]-.*)" 268 | 269 | You can also redefine: 270 | 271 | DIR_IDENTITIES = "$HOME/.ssh/identities" 272 | DIR_AGENTS = "$HOME/.ssh/agents" 273 | 274 | To point somewhere else if you so desire. 275 | 276 | 277 | BUILDING A DEBIAN PACKAGE 278 | ========================= 279 | 280 | If you need to use ssh-ident on a debian / ubuntu / or any other 281 | derivate, you can now build debian packages. 282 | 283 | 1. Make sure you have devscripts installed: 284 | 285 | sudo apt-get install devscripts debhelper 286 | 287 | 2. Download ssh-ident in a directory of your choice (ssh-ident) 288 | 289 | git clone https://github.com/ccontavalli/ssh-ident.git ssh-ident 290 | 291 | 3. Build the .deb package: 292 | 293 | cd ssh-ident && debuild -us -uc 294 | 295 | 4. Profit: 296 | 297 | cd ..; dpkg -i ssh-ident*.deb 298 | 299 | 300 | CREDITS 301 | ======= 302 | 303 | - Carlo Contavalli, http://www.github.com/ccontavalli, main author. 304 | - Hubert depesz Lubaczewski, http://www.github.com/despez, support 305 | for using environment variables for configuration. 306 | - Flip Hess, http://www.github.com/fliphess, support for building 307 | a .deb out of ssh-ident. 308 | - Terrel Shumway, https://www.github.com/scholarly, port to python3. 309 | - black2754, https://www.github.com/black2754, vim modeline, support 310 | for verbosity settings, and BatchMode passing. 311 | - Michael Heap, https://www.github.com/mheap, support for per 312 | identities config files. 313 | - Carl Drougge, https://www.github.com/drougge, CVE-2016-0777 fix, 314 | fix for per user config files, and use /bin/env instead of python 315 | path. 316 | 317 | CLASSES 318 | __builtin__.object 319 | AgentManager 320 | Config 321 | SshIdentPrint 322 | 323 | class AgentManager(__builtin__.object) 324 | | Manages the ssh-agent for one identity. 325 | | 326 | | Methods defined here: 327 | | 328 | | FindUnloadedKeys(self, keys) 329 | | Determines which keys have not been loaded yet. 330 | | 331 | | Args: 332 | | keys: dict as returned by FindKeys. 333 | | 334 | | Returns: 335 | | iterable of strings, paths to private key files to load. 336 | | 337 | | GetLoadedKeys(self) 338 | | Returns an iterable of strings, each the fingerprint of a loaded key. 339 | | 340 | | GetShellArgs(self) 341 | | Returns the flags to be passed to the shell to run a command. 342 | | 343 | | LoadKeyFiles(self, keys) 344 | | Load all specified keys. 345 | | 346 | | Args: 347 | | keys: iterable of strings, each string a path to a key to load. 348 | | 349 | | LoadUnloadedKeys(self, keys) 350 | | Loads all the keys specified that are not loaded. 351 | | 352 | | Args: 353 | | keys: dict as returned by FindKeys. 354 | | 355 | | RunSSH(self, argv) 356 | | Execs ssh with the specified arguments. 357 | | 358 | | __init__(self, identity, sshconfig, config) 359 | | Initializes an AgentManager object. 360 | | 361 | | Args: 362 | | identity: string, identity the ssh-agent managed by this instance of 363 | | an AgentManager will control. 364 | | config: object implementing the Config interface, allows access to 365 | | the user configuration parameters. 366 | | 367 | | Attributes: 368 | | identity: same as above. 369 | | config: same as above. 370 | | agents_path: directory where the config of all agents is kept. 371 | | agent_file: the config of the agent corresponding to this identity. 372 | | 373 | | Parameters: 374 | | DIR_AGENTS: used to compute agents_path. 375 | | BINARY_SSH: path to the ssh binary. 376 | | 377 | | ---------------------------------------------------------------------- 378 | | Static methods defined here: 379 | | 380 | | EscapeShellArguments(argv) 381 | | Escapes all arguments to the shell, returns a string. 382 | | 383 | | GetAgentFile(path, identity) 384 | | Returns the path to an agent config file. 385 | | 386 | | Args: 387 | | path: string, the path where agent config files are kept. 388 | | identity: string, identity for which to load the agent. 389 | | 390 | | Returns: 391 | | string, path to the agent file. 392 | | 393 | | GetPublicKeyFingerprint(key) 394 | | Returns the fingerprint of a public key as a string. 395 | | 396 | | IsAgentFileValid(agentfile) 397 | | Returns true if the specified agentfile refers to a running agent. 398 | | 399 | | RunShellCommand(command) 400 | | Runs a shell command, returns (status, stdout), (int, string). 401 | | 402 | | RunShellCommandInAgent(agentfile, command, stdin=None, stdout=-1) 403 | | Runs a shell command with an agent configured in the environment. 404 | | 405 | | ---------------------------------------------------------------------- 406 | | Data descriptors defined here: 407 | | 408 | | __dict__ 409 | | dictionary for instance variables (if defined) 410 | | 411 | | __weakref__ 412 | | list of weak references to the object (if defined) 413 | 414 | class Config(__builtin__.object) 415 | | Holds and loads users configurations. 416 | | 417 | | Methods defined here: 418 | | 419 | | Get(self, parameter) 420 | | Returns the value of a parameter, or causes the script to exit. 421 | | 422 | | Load(self) 423 | | Load configurations from the default user file. 424 | | 425 | | Set(self, parameter, value) 426 | | Sets configuration option parameter to value. 427 | | 428 | | __init__(self) 429 | | 430 | | ---------------------------------------------------------------------- 431 | | Static methods defined here: 432 | | 433 | | Expand(value) 434 | | Expand environment variables or ~ in string parameters. 435 | | 436 | | ---------------------------------------------------------------------- 437 | | Data descriptors defined here: 438 | | 439 | | __dict__ 440 | | dictionary for instance variables (if defined) 441 | | 442 | | __weakref__ 443 | | list of weak references to the object (if defined) 444 | | 445 | | ---------------------------------------------------------------------- 446 | | Data and other attributes defined here: 447 | | 448 | | defaults = {'BINARY_DIR': None, 'BINARY_SSH': None, 'DEFAULT_IDENTITY'... 449 | 450 | class SshIdentPrint(__builtin__.object) 451 | | Wrapper around python's print function. 452 | | 453 | | Methods defined here: 454 | | 455 | | __call__ = write(self, *args, **kwargs) 456 | | 457 | | __init__(self, config) 458 | | config: object implementing the Config interface, allows access to 459 | | the user configuration parameters. 460 | | 461 | | Attributes: 462 | | config: same as above. 463 | | python_print: python's print function (hopefully) 464 | | 465 | | Parameters: 466 | | SSH_BATCH_MODE: used to check if messages should be printed or not 467 | | VERBOSITY: used to check if messages should be printed or not 468 | | 469 | | write(self, *args, **kwargs) 470 | | Passes all parameters to python's print, 471 | | unless output is disabled by the configuration. 472 | | The interface is compatible with python's print, but supports the 473 | | optional parameter 'loglevel' in addition. 474 | | 475 | | ---------------------------------------------------------------------- 476 | | Data descriptors defined here: 477 | | 478 | | __dict__ 479 | | dictionary for instance variables (if defined) 480 | | 481 | | __weakref__ 482 | | list of weak references to the object (if defined) 483 | 484 | FUNCTIONS 485 | AutodetectBinary(argv, config) 486 | Detects the correct binary to run and sets BINARY_SSH accordingly, 487 | if it is not already set. 488 | 489 | FindIdentity(argv, config) 490 | Returns the identity to use based on current directory or argv. 491 | 492 | Args: 493 | argv: iterable of string, argv passed to this program. 494 | config: instance of an object implementing the same interface as 495 | the Config class. 496 | 497 | Returns: 498 | string, the name of the identity to use. 499 | 500 | FindIdentityInList(elements, identities) 501 | Matches a list of identities to a list of elements. 502 | 503 | Args: 504 | elements: iterable of strings, arbitrary strings to match on. 505 | identities: iterable of (string, string), with first string 506 | being a regular expression, the second string being an identity. 507 | 508 | Returns: 509 | The identity specified in identities for the first regular expression 510 | matching the first element in elements. 511 | 512 | FindKeys(identity, config) 513 | Finds all the private and public keys associated with an identity. 514 | 515 | Args: 516 | identity: string, name of the identity to load strings of. 517 | config: object implementing the Config interface, providing configurations 518 | for the user. 519 | 520 | Returns: 521 | dict, {"key name": {"pub": "/path/to/public/key", "priv": 522 | "/path/to/private/key"}}, for each key found, the path of the public 523 | key and private key. The key name is just a string representing the 524 | key. Note that for a given key, it is not guaranteed that both the 525 | public and private key will be found. 526 | The return value is affected by DIR_IDENTITIES and PATTERN_KEYS 527 | configuration parameters. 528 | 529 | FindSSHConfig(identity, config) 530 | Finds a config file if there's one associated with an identity 531 | 532 | Args: 533 | identity: string, name of the identity to load strings of. 534 | config: object implementing the Config interface, providing configurations 535 | for the user. 536 | 537 | Returns: 538 | string, the configuration file to use 539 | 540 | GetSessionTty() 541 | Returns a file descriptor for the session TTY, or None. 542 | 543 | In *nix systems, each process is tied to one session. Each 544 | session can be tied (or not) to a terminal, "/dev/tty". 545 | 546 | Additionally, when a command is run, its stdin or stdout can 547 | be any file descriptor, including one that represent a tty. 548 | 549 | So for example: 550 | 551 | ./test.sh < /dev/null > /dev/null 552 | 553 | will have stdin and stdout tied to /dev/null - but does not 554 | tell us anything about the session having a /dev/tty associated 555 | or not. 556 | 557 | For example, running 558 | 559 | ssh -t user@remotehost './test.sh < /dev/null > /dev/null' 560 | 561 | have a tty associated, while the same command without -t will not. 562 | 563 | When ssh is invoked by tools like git or rsyn, its stdin and stdout 564 | is often tied to a file descriptor which is not a terminal, has 565 | the tool wants to provide the input and process the output. 566 | 567 | ssh-ident internally has to invoke ssh-add, which needs to know if 568 | it has any terminal it can use at all. 569 | 570 | This function returns an open file if the session has an usable terminal, 571 | None otherwise. 572 | 573 | ParseCommandLine(argv, config) 574 | Parses the command line parameters in argv 575 | and modifies config accordingly. 576 | 577 | ShouldPrint(config, loglevel) 578 | Returns true if a message by the specified loglevel should be printed. 579 | 580 | main(argv) 581 | 582 | DATA 583 | LOG_CONSTANTS = {'LOG_DEBUG': 4, 'LOG_ERROR': 1, 'LOG_INFO': 3, 'LOG_W... 584 | LOG_DEBUG = 4 585 | LOG_ERROR = 1 586 | LOG_INFO = 3 587 | LOG_WARN = 2 588 | print_function = _Feature((2, 6, 0, 'alpha', 2), (3, 0, 0, 'alpha', 0)... 589 | 590 | 591 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | ssh-ident (20140619.2318.1) unstable; urgency=low 2 | 3 | * Setup initial debian package 4 | 5 | -- Flip Hess Thu, 19 Jun 2014 23:22:22 +0200 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: ssh-ident 2 | Maintainer: Carlo Contavalli (ccontavalli@gmail.com). 3 | Section: admin 4 | Priority: extra 5 | Build-Depends: debhelper (>= 7) 6 | Standards-Version: 3.9.4 7 | Vcs-Git: git://github.com/ccontavalli/ssh-ident 8 | Vcs-Browser: https://github.com/ccontavalli/ssh-ident 9 | 10 | Package: ssh-ident 11 | Architecture: all 12 | Depends: bash 13 | Description: ssh-ident - Start and use ssh-agent and load identities as necessary 14 | Use this script to start ssh-agents and load ssh keys on demand, 15 | when they are first needed. 16 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Files: * 2 | Copyright (c) 2012,2013 Carlo Contavalli (ccontavalli@gmail.com). 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY Carlo Contavalli ''AS IS'' AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 17 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 18 | EVENT SHALL Carlo Contavalli OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 19 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | The views and conclusions contained in the software and documentation are 27 | those of the authors and should not be interpreted as representing official 28 | policies, either expressed or implied, of Carlo Contavalli. 29 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ 5 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /debian/ssh-ident.docs: -------------------------------------------------------------------------------- 1 | README 2 | -------------------------------------------------------------------------------- /debian/ssh-ident.install: -------------------------------------------------------------------------------- 1 | ssh-ident usr/sbin/ 2 | -------------------------------------------------------------------------------- /ssh-ident: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: tabstop=2 shiftwidth=2 expandtab 3 | """Start and use ssh-agent and load identities as necessary. 4 | 5 | Use this script to start ssh-agents and load ssh keys on demand, 6 | when they are first needed. 7 | 8 | All you have to do is modify your .bashrc to have: 9 | 10 | alias ssh='/path/to/ssh-ident' 11 | 12 | or add a link to ssh-ident from a directory in your PATH, for example: 13 | 14 | ln -s /path/to/ssh-ident ~/bin/ssh 15 | 16 | If you use scp or rsync regularly, you should add a few more lines described 17 | below. 18 | 19 | In any case, ssh-ident: 20 | 21 | - will create an ssh-agent and load the keys you need the first time you 22 | actually need them, once. No matter how many terminals, ssh or login 23 | sessions you have, no matter if your home is shared via NFS. 24 | 25 | - can prepare and use a different agent and different set of keys depending 26 | on the host you are connecting to, or the directory you are using ssh 27 | from. 28 | This allows for isolating keys when using agent forwarding with different 29 | sites (eg, university, work, home, secret evil internet identity, ...). 30 | It also allows to use multiple accounts on sites like github, unfuddle 31 | and gitorious easily. 32 | 33 | - allows to specify different options for each set of keys. For example, you 34 | can provide a -t 60 to keep keys loaded for at most 60 seconds. Or -c to 35 | always ask for confirmation before using a key. 36 | 37 | 38 | Installation 39 | ============ 40 | 41 | All you need to run ssh-ident is a standard installation of python >= 2.6, 42 | python > 3 is supported. 43 | 44 | If your system has wget and are impatient to use it, you can install 45 | ssh-ident with two simple commands: 46 | 47 | mkdir -p ~/bin; wget -O ~/bin/ssh goo.gl/MoJuKB; chmod 0755 ~/bin/ssh 48 | 49 | echo 'export PATH=~/bin:$PATH' >> ~/.bashrc 50 | 51 | Logout, login, and done. SSH should now invoke ssh-ident instead of the 52 | standard ssh. 53 | 54 | 55 | Alternatives 56 | ============ 57 | 58 | In .bashrc, I have: 59 | 60 | alias ssh=/home/ccontavalli/scripts/ssh-ident 61 | 62 | all I have to do now is logout, login and then: 63 | 64 | $ ssh somewhere 65 | 66 | ssh-ident will be called instead of ssh, and it will: 67 | - check if an agent is running. If not, it will start one. 68 | - try to load all the keys in ~/.ssh, if not loaded. 69 | 70 | If I now ssh again, or somewhere else, ssh-ident will reuse the same agent 71 | and the same keys, if valid. 72 | 73 | 74 | About scp, rsync, and friends 75 | ============================= 76 | 77 | scp, rsync, and most similar tools internally invoke ssh. If you don't tell 78 | them to use ssh-ident instead, key loading won't work. There are a few ways 79 | to solve the problem: 80 | 81 | 1) Rename 'ssh-ident' to 'ssh' or create a symlink 'ssh' pointing to 82 | ssh-ident in a directory in your PATH before /usr/bin or /bin, similarly 83 | to what was described previously. For example, add to your .bashrc: 84 | 85 | export PATH="~/bin:$PATH" 86 | 87 | And run: 88 | 89 | ln -s /path/to/ssh-ident ~/bin/ssh 90 | 91 | Make sure `echo $PATH` shows '~/bin' *before* '/usr/bin' or '/bin'. You 92 | can verify this is working as expected with `which ssh`, which should 93 | show ~/bin/ssh. 94 | 95 | This works for rsync and git, among others, but not for scp and sftp, as 96 | these do not look for ssh in your PATH but use a hard-coded path to the 97 | binary. 98 | 99 | If you want to use ssh-ident with scp or sftp, you can simply create 100 | symlinks for them as well: 101 | 102 | ln -s /path/to/ssh-ident ~/bin/scp 103 | ln -s /path/to/ssh-ident ~/bin/sftp 104 | 105 | 2) Add a few more aliases in your .bashrc file, for example: 106 | 107 | alias scp='BINARY_SSH=scp /path/to/ssh-ident' 108 | alias rsync='BINARY_SSH=rsync /path/to/ssh-ident' 109 | ... 110 | 111 | The first alias will make the 'scp' command invoke 'ssh-ident' instead, 112 | but tell 'ssh-ident' to invoke 'scp' instead of the plain 'ssh' command 113 | after loading the necessary agents and keys. 114 | 115 | Note that aliases don't work from scripts - if you have any script that 116 | you expect to use with ssh-ident, you may prefer method 1), or you will 117 | need to update the script accordingly. 118 | 119 | 3) Use command specific methods to force them to use ssh-ident instead of 120 | ssh, for example: 121 | 122 | rsync -e '/path/to/ssh-ident' ... 123 | scp -S '/path/to/ssh-ident' ... 124 | 125 | 4) Replace the real ssh on the system with ssh-ident, and set the 126 | BINARY_SSH configuration parameter to the original value. 127 | 128 | On Debian based system, you can make this change in a way that 129 | will survive automated upgrades and audits by running: 130 | 131 | dpkg-divert --divert /usr/bin/ssh.ssh-ident --rename /usr/bin/ssh 132 | 133 | After which, you will need to use: 134 | 135 | BINARY_SSH="/usr/bin/ssh.ssh-ident" 136 | 137 | 138 | Config file with multiple identities 139 | ==================================== 140 | 141 | To have multiple identities, all I have to do is: 142 | 143 | 1) create a ~/.ssh-ident file. In this file, I need to tell ssh-ident which 144 | identities to use and when. The file should look something like: 145 | 146 | # Specifies which identity to use depending on the path I'm running ssh 147 | # from. 148 | # For example: ("mod-xslt", "personal") means that for any path that 149 | # contains the word "mod-xslt", the "personal" identity should be used. 150 | # This is optional - don't include any MATCH_PATH if you don't need it. 151 | MATCH_PATH = [ 152 | # (directory pattern, identity) 153 | (r"mod-xslt", "personal"), 154 | (r"ssh-ident", "personal"), 155 | (r"opt/work", "work"), 156 | (r"opt/private", "secret"), 157 | ] 158 | 159 | # If any of the ssh arguments have 'cweb' in it, the 'personal' identity 160 | # has to be used. For example: "ssh myhost.cweb.com" will have cweb in 161 | # argv, and the "personal" identity will be used. 162 | # This is optional - don't include any MATCH_ARGV if you don't 163 | # need it. 164 | MATCH_ARGV = [ 165 | (r"cweb", "personal"), 166 | (r"corp", "work"), 167 | ] 168 | 169 | # Note that if no match is found, the DEFAULT_IDENTITY is used. This is 170 | # generally your loginname, no need to change it. 171 | # This is optional - don't include any DEFAULT_IDENTITY if you don't 172 | # need it. 173 | # DEFAULT_IDENTITY = "foo" 174 | 175 | # This is optional - don't include any SSH_ADD_OPTIONS if you don't 176 | # need it. 177 | SSH_ADD_OPTIONS = { 178 | # Regardless, ask for confirmation before using any of the 179 | # work keys. 180 | "work": "-c", 181 | # Forget about secret keys after ten minutes. ssh-ident will 182 | # automatically ask you your passphrase again if they are needed. 183 | "secret": "-t 600", 184 | } 185 | 186 | # This is optional - dont' include any SSH_OPTIONS if you don't 187 | # need it. 188 | # Otherwise, provides options to be passed to 'ssh' for specific 189 | # identities. 190 | SSH_OPTIONS = { 191 | # Disable forwarding of the agent, but enable X forwarding, 192 | # when using the work profile. 193 | "work": "-Xa", 194 | 195 | # Always forward the agent when using the secret identity. 196 | "secret": "-A", 197 | } 198 | 199 | # Options to pass to ssh by default. 200 | # If you don't specify anything, UserRoaming=no is passed, due 201 | # to CVE-2016-0777. Leave it empty to disable this. 202 | SSH_DEFAULT_OPTIONS = "-oUseRoaming=no" 203 | 204 | # Which options to use by default if no match with SSH_ADD_OPTIONS 205 | # was found. Note that ssh-ident hard codes -t 7200 to prevent your 206 | # keys from remaining in memory for too long. 207 | SSH_ADD_DEFAULT_OPTIONS = "-t 7200" 208 | 209 | # Output verbosity 210 | # valid values are: LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG 211 | VERBOSITY = LOG_INFO 212 | 213 | 2) Create the directory where all the identities and agents 214 | will be kept: 215 | 216 | $ mkdir -p ~/.ssh/identities; chmod u=rwX,go= -R ~/.ssh 217 | 218 | 3) Create a directory for each identity, for example: 219 | 220 | $ mkdir -p ~/.ssh/identities/personal 221 | $ mkdir -p ~/.ssh/identities/work 222 | $ mkdir -p ~/.ssh/identities/secret 223 | 224 | 4) Generate (or copy) keys for those identities: 225 | 226 | # Default keys are for my personal account 227 | $ cp ~/.ssh/id_rsa* ~/.ssh/identities/personal 228 | 229 | # Generate keys to be used for work only, rsa 230 | $ ssh-keygen -t rsa -b 4096 -f ~/.ssh/identities/work/id_rsa 231 | 232 | ... 233 | 234 | 235 | Now if I run: 236 | 237 | $ ssh corp.mywemployer.com 238 | 239 | ssh-ident will be invoked instead, and: 240 | 241 | 1) check ssh argv, determine that the "work" identity has to be used. 242 | 2) look in ~/.ssh/agents, for a "work" agent loaded. If there is no 243 | agent, it will prepare one. 244 | 3) look in ~/.ssh/identities/work/* for a list of keys to load for this 245 | identity. It will try to load any key that is not already loaded in 246 | the agent. 247 | 4) finally run ssh with the environment setup such that it will have 248 | access only to the agent for the identity work, and the corresponding 249 | keys. 250 | 251 | Note that ssh-ident needs to access both your private and public keys. Note 252 | also that it identifies public keys by the .pub extension. All files in your 253 | identities subdirectories will be considered keys. 254 | 255 | If you want to only load keys that have "key" in the name, you can add 256 | to your .ssh-ident: 257 | 258 | PATTERN_KEYS = "key" 259 | 260 | The default is: 261 | 262 | PATTERN_KEYS = r"/(id_.*|identity.*|ssh[0-9]-.*)" 263 | 264 | You can also redefine: 265 | 266 | DIR_IDENTITIES = "$HOME/.ssh/identities" 267 | DIR_AGENTS = "$HOME/.ssh/agents" 268 | 269 | To point somewhere else if you so desire. 270 | 271 | 272 | BUILDING A DEBIAN PACKAGE 273 | ========================= 274 | 275 | If you need to use ssh-ident on a debian / ubuntu / or any other 276 | derivate, you can now build debian packages. 277 | 278 | 1. Make sure you have devscripts installed: 279 | 280 | sudo apt-get install devscripts debhelper 281 | 282 | 2. Download ssh-ident in a directory of your choice (ssh-ident) 283 | 284 | git clone https://github.com/ccontavalli/ssh-ident.git ssh-ident 285 | 286 | 3. Build the .deb package: 287 | 288 | cd ssh-ident && debuild -us -uc 289 | 290 | 4. Profit: 291 | 292 | cd ..; dpkg -i ssh-ident*.deb 293 | 294 | 295 | CREDITS 296 | ======= 297 | 298 | - Carlo Contavalli, http://www.github.com/ccontavalli, main author. 299 | - Hubert depesz Lubaczewski, http://www.github.com/despez, support 300 | for using environment variables for configuration. 301 | - Flip Hess, http://www.github.com/fliphess, support for building 302 | a .deb out of ssh-ident. 303 | - Terrel Shumway, https://www.github.com/scholarly, port to python3. 304 | - black2754, https://www.github.com/black2754, vim modeline, support 305 | for verbosity settings, and BatchMode passing. 306 | - Michael Heap, https://www.github.com/mheap, support for per 307 | identities config files. 308 | - Carl Drougge, https://www.github.com/drougge, CVE-2016-0777 fix, 309 | fix for per user config files, and use /bin/env instead of python 310 | path. 311 | """ 312 | 313 | from __future__ import print_function 314 | 315 | import collections 316 | import shutil 317 | import errno 318 | import fcntl 319 | import getpass 320 | import glob 321 | import os 322 | import re 323 | import socket 324 | import subprocess 325 | import sys 326 | import termios 327 | import textwrap 328 | 329 | # constants so noone has deal with cryptic numbers 330 | LOG_CONSTANTS = {"LOG_ERROR": 1, "LOG_WARN": 2, "LOG_INFO": 3, "LOG_DEBUG": 4} 331 | # load them directly into the global scope, for easy use 332 | # not exactly pretty... 333 | globals().update(LOG_CONSTANTS) 334 | 335 | 336 | def ShouldPrint(config, loglevel): 337 | """Returns true if a message by the specified loglevel should be printed.""" 338 | # determine the current output verbosity 339 | verbosity = config.Get("VERBOSITY") 340 | 341 | # verbosity may be a string, e.g. 'LOG_INFO' 342 | # this happens when it comes from the OS env, but also if quotes are 343 | # used in the config file 344 | if isinstance(verbosity, str): 345 | if verbosity in LOG_CONSTANTS: 346 | # resolve the loglevel, e.g. 'LOG_INFO' -> 3 347 | verbosity = LOG_CONSTANTS[verbosity] 348 | else: 349 | # the string may also be a number, e.g. '3' -> 3 350 | verbosity = int(verbosity) 351 | if loglevel <= verbosity: 352 | return True 353 | return False 354 | 355 | 356 | class SshIdentPrint(object): 357 | """Wrapper around python's print function.""" 358 | 359 | def __init__(self, config): 360 | """ 361 | config: object implementing the Config interface, allows access to 362 | the user configuration parameters. 363 | 364 | Attributes: 365 | config: same as above. 366 | python_print: python's print function (hopefully) 367 | 368 | Parameters: 369 | SSH_BATCH_MODE: used to check if messages should be printed or not 370 | VERBOSITY: used to check if messages should be printed or not 371 | """ 372 | self.config = config 373 | self.python_print = print 374 | 375 | def write(self, *args, **kwargs): 376 | """Passes all parameters to python's print, 377 | unless output is disabled by the configuration. 378 | The interface is compatible with python's print, but supports the 379 | optional parameter 'loglevel' in addition.""" 380 | if self.config.Get("SSH_BATCH_MODE"): 381 | # no output in BatchMode 382 | return 383 | 384 | # determine the loglevel of this message 385 | if "loglevel" in kwargs: 386 | loglevel = kwargs["loglevel"] 387 | # make sure not to pass the loglevel parameter to print 388 | del kwargs["loglevel"] 389 | else: 390 | # if the loglevel is not given, default to INFO 391 | loglevel = LOG_INFO 392 | 393 | if ShouldPrint(self.config, loglevel): 394 | self.python_print(*args, **kwargs) 395 | 396 | __call__ = write 397 | 398 | 399 | class Config(object): 400 | """Holds and loads users configurations.""" 401 | 402 | defaults = { 403 | # Where to find the per-user configuration. 404 | "FILE_USER_CONFIG": "$HOME/.ssh-ident", 405 | 406 | # Where to find all the identities for the user. 407 | "DIR_IDENTITIES": "$HOME/.ssh/identities", 408 | # Where to keep the information about each running agent. 409 | "DIR_AGENTS": "$HOME/.ssh/agents", 410 | 411 | # How to identify key files in the identities directory. 412 | "PATTERN_KEYS": r"/(id_.*|identity.*|ssh[0-9]-.*)", 413 | 414 | # How to identify ssh config files. 415 | "PATTERN_CONFIG": r"/config$", 416 | 417 | # Dictionary with identity as a key, automatically adds 418 | # the specified options to the ssh command run. 419 | "SSH_OPTIONS": {}, 420 | # Additional options to append to ssh by default. 421 | "SSH_DEFAULT_OPTIONS": "-oUseRoaming=no", 422 | 423 | # Complete path of full ssh binary to use. If not set, ssh-ident will 424 | # try to find the correct binary in PATH. 425 | "BINARY_SSH": None, 426 | "BINARY_DIR": None, 427 | 428 | # Which identity to use by default if we cannot tell from 429 | # the current working directory and/or argv. 430 | "DEFAULT_IDENTITY": "$USER", 431 | 432 | # Those should really be overridden by the user. Look 433 | # at the documentation for more details. 434 | "MATCH_PATH": [], 435 | "MATCH_ARGV": [], 436 | 437 | # Dictionary with identity as a key, allows to specify 438 | # per identity options when using ssh-add. 439 | "SSH_ADD_OPTIONS": {}, 440 | # ssh-add default options. By default, don't keep a key longer 441 | # than 2 hours. 442 | "SSH_ADD_DEFAULT_OPTIONS": "-t 7200", 443 | 444 | # Like BatchMode in ssh, see man 5 ssh_config. 445 | # In BatchMode ssh-ident will not print any output and not ask for 446 | # any passphrases. 447 | "SSH_BATCH_MODE": False, 448 | 449 | # Output verbosity 450 | # valid values are: LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG 451 | # use 0 to disable ALL output (not recommended!) 452 | "VERBOSITY": LOG_INFO, 453 | } 454 | 455 | def __init__(self): 456 | self.values = {} 457 | 458 | def Load(self): 459 | """Load configurations from the default user file.""" 460 | path = self.Get("FILE_USER_CONFIG") 461 | variables = {} 462 | try: 463 | exec(compile(open(path).read(), path, 'exec'), LOG_CONSTANTS, variables) 464 | except IOError: 465 | return self 466 | self.values = variables 467 | return self 468 | 469 | @staticmethod 470 | def Expand(value): 471 | """Expand environment variables or ~ in string parameters.""" 472 | if isinstance(value, str): 473 | return os.path.expanduser(os.path.expandvars(value)) 474 | return value 475 | 476 | def Get(self, parameter): 477 | """Returns the value of a parameter, or causes the script to exit.""" 478 | if parameter in os.environ: 479 | return self.Expand(os.environ[parameter]) 480 | if parameter in self.values: 481 | return self.Expand(self.values[parameter]) 482 | if parameter in self.defaults: 483 | return self.Expand(self.defaults[parameter]) 484 | 485 | print( 486 | "Parameter '{0}' needs to be defined in " 487 | "config file or defaults".format(parameter), file=sys.stderr, 488 | loglevel=LOG_ERROR) 489 | sys.exit(2) 490 | 491 | def Set(self, parameter, value): 492 | """Sets configuration option parameter to value.""" 493 | self.values[parameter] = value 494 | 495 | def FindIdentityInList(elements, identities): 496 | """Matches a list of identities to a list of elements. 497 | 498 | Args: 499 | elements: iterable of strings, arbitrary strings to match on. 500 | identities: iterable of (string, string), with first string 501 | being a regular expression, the second string being an identity. 502 | 503 | Returns: 504 | The identity specified in identities for the first regular expression 505 | matching the first element in elements. 506 | """ 507 | for element in elements: 508 | for regex, identity in identities: 509 | if re.search(regex, element): 510 | return identity 511 | return None 512 | 513 | def FindIdentity(argv, config): 514 | """Returns the identity to use based on current directory or argv. 515 | 516 | Args: 517 | argv: iterable of string, argv passed to this program. 518 | config: instance of an object implementing the same interface as 519 | the Config class. 520 | 521 | Returns: 522 | string, the name of the identity to use. 523 | """ 524 | paths = set([os.getcwd(), os.path.abspath(os.getcwd()), os.path.normpath(os.getcwd())]) 525 | return ( 526 | FindIdentityInList(argv, config.Get("MATCH_ARGV")) or 527 | FindIdentityInList(paths, config.Get("MATCH_PATH")) or 528 | config.Get("DEFAULT_IDENTITY")) 529 | 530 | def FindKeys(identity, config): 531 | """Finds all the private and public keys associated with an identity. 532 | 533 | Args: 534 | identity: string, name of the identity to load strings of. 535 | config: object implementing the Config interface, providing configurations 536 | for the user. 537 | 538 | Returns: 539 | dict, {"key name": {"pub": "/path/to/public/key", "priv": 540 | "/path/to/private/key"}}, for each key found, the path of the public 541 | key and private key. The key name is just a string representing the 542 | key. Note that for a given key, it is not guaranteed that both the 543 | public and private key will be found. 544 | The return value is affected by DIR_IDENTITIES and PATTERN_KEYS 545 | configuration parameters. 546 | """ 547 | directories = [os.path.join(config.Get("DIR_IDENTITIES"), identity)] 548 | if identity == getpass.getuser(): 549 | directories.append(os.path.expanduser("~/.ssh")) 550 | 551 | pattern = re.compile(config.Get("PATTERN_KEYS")) 552 | found = collections.defaultdict(dict) 553 | for directory in directories: 554 | try: 555 | keyfiles = os.listdir(directory) 556 | except OSError as e: 557 | if e.errno == errno.ENOENT: 558 | continue 559 | raise 560 | 561 | for key in keyfiles: 562 | key = os.path.join(directory, key) 563 | if not os.path.isfile(key): 564 | continue 565 | if not pattern.search(key): 566 | continue 567 | 568 | kinds = ( 569 | ("private", "priv"), 570 | ("public", "pub"), 571 | (".pub", "pub"), 572 | ("", "priv"), 573 | ) 574 | for match, kind in kinds: 575 | if match in key: 576 | found[key.replace(match, "")][kind] = key 577 | 578 | if not found: 579 | print("Warning: no keys found for identity {0} in:".format(identity), 580 | file=sys.stderr, 581 | loglevel=LOG_WARN) 582 | print(directories, file=sys.stderr, loglevel=LOG_WARN) 583 | 584 | return found 585 | 586 | 587 | def FindSSHConfig(identity, config): 588 | """Finds a config file if there's one associated with an identity 589 | 590 | Args: 591 | identity: string, name of the identity to load strings of. 592 | config: object implementing the Config interface, providing configurations 593 | for the user. 594 | 595 | Returns: 596 | string, the configuration file to use 597 | """ 598 | directories = [os.path.join(config.Get("DIR_IDENTITIES"), identity)] 599 | 600 | pattern = re.compile(config.Get("PATTERN_CONFIG")) 601 | sshconfigs = collections.defaultdict(dict) 602 | for directory in directories: 603 | try: 604 | sshconfigs = os.listdir(directory) 605 | except OSError as e: 606 | if e.errno == errno.ENOENT: 607 | continue 608 | raise 609 | 610 | for sshconfig in sshconfigs: 611 | sshconfig = os.path.join(directory, sshconfig) 612 | if os.path.isfile(sshconfig) and pattern.search(sshconfig): 613 | return sshconfig 614 | 615 | return False 616 | 617 | 618 | def GetSessionTty(): 619 | """Returns a file descriptor for the session TTY, or None. 620 | 621 | In *nix systems, each process is tied to one session. Each 622 | session can be tied (or not) to a terminal, "/dev/tty". 623 | 624 | Additionally, when a command is run, its stdin or stdout can 625 | be any file descriptor, including one that represent a tty. 626 | 627 | So for example: 628 | 629 | ./test.sh < /dev/null > /dev/null 630 | 631 | will have stdin and stdout tied to /dev/null - but does not 632 | tell us anything about the session having a /dev/tty associated 633 | or not. 634 | 635 | For example, running 636 | 637 | ssh -t user@remotehost './test.sh < /dev/null > /dev/null' 638 | 639 | have a tty associated, while the same command without -t will not. 640 | 641 | When ssh is invoked by tools like git or rsyn, its stdin and stdout 642 | is often tied to a file descriptor which is not a terminal, has 643 | the tool wants to provide the input and process the output. 644 | 645 | ssh-ident internally has to invoke ssh-add, which needs to know if 646 | it has any terminal it can use at all. 647 | 648 | This function returns an open file if the session has an usable terminal, 649 | None otherwise. 650 | """ 651 | try: 652 | fd = open("/dev/tty", "r") 653 | fcntl.ioctl(fd, termios.TIOCGPGRP, " ") 654 | except IOError: 655 | return None 656 | return fd 657 | 658 | 659 | class AgentManager(object): 660 | """Manages the ssh-agent for one identity.""" 661 | 662 | def __init__(self, identity, sshconfig, config): 663 | """Initializes an AgentManager object. 664 | 665 | Args: 666 | identity: string, identity the ssh-agent managed by this instance of 667 | an AgentManager will control. 668 | config: object implementing the Config interface, allows access to 669 | the user configuration parameters. 670 | 671 | Attributes: 672 | identity: same as above. 673 | config: same as above. 674 | agents_path: directory where the config of all agents is kept. 675 | agent_file: the config of the agent corresponding to this identity. 676 | 677 | Parameters: 678 | DIR_AGENTS: used to compute agents_path. 679 | BINARY_SSH: path to the ssh binary. 680 | """ 681 | self.identity = identity 682 | self.config = config 683 | self.ssh_config = sshconfig 684 | self.agents_path = os.path.abspath(config.Get("DIR_AGENTS")) 685 | self.agent_file = self.GetAgentFile(self.agents_path, self.identity) 686 | 687 | def LoadUnloadedKeys(self, keys): 688 | """Loads all the keys specified that are not loaded. 689 | 690 | Args: 691 | keys: dict as returned by FindKeys. 692 | """ 693 | toload = self.FindUnloadedKeys(keys) 694 | if toload: 695 | print("Loading keys:\n {0}".format( "\n ".join(toload)), 696 | file=sys.stderr, loglevel=LOG_INFO) 697 | self.LoadKeyFiles(toload) 698 | else: 699 | print("All keys already loaded", file=sys.stderr, loglevel=LOG_INFO) 700 | 701 | def FindUnloadedKeys(self, keys): 702 | """Determines which keys have not been loaded yet. 703 | 704 | Args: 705 | keys: dict as returned by FindKeys. 706 | 707 | Returns: 708 | iterable of strings, paths to private key files to load. 709 | """ 710 | loaded = set(self.GetLoadedKeys()) 711 | toload = set() 712 | for key, config in keys.items(): 713 | if "pub" not in config: 714 | continue 715 | if "priv" not in config: 716 | continue 717 | 718 | fingerprint = self.GetPublicKeyFingerprint(config["pub"]) 719 | if fingerprint in loaded: 720 | continue 721 | 722 | toload.add(config["priv"]) 723 | return toload 724 | 725 | def LoadKeyFiles(self, keys): 726 | """Load all specified keys. 727 | 728 | Args: 729 | keys: iterable of strings, each string a path to a key to load. 730 | """ 731 | keys = " ".join(keys) 732 | options = self.config.Get("SSH_ADD_OPTIONS").get( 733 | self.identity, self.config.Get("SSH_ADD_DEFAULT_OPTIONS")) 734 | console = GetSessionTty() 735 | self.RunShellCommandInAgent( 736 | self.agent_file, "ssh-add {0} {1}".format(options, keys), 737 | stdout=console, stdin=console) 738 | 739 | def GetLoadedKeys(self): 740 | """Returns an iterable of strings, each the fingerprint of a loaded key.""" 741 | retval, stdout = self.RunShellCommandInAgent(self.agent_file, "ssh-add -l") 742 | if retval != 0: 743 | return [] 744 | 745 | fingerprints = [] 746 | for line in stdout.decode("utf-8").split("\n"): 747 | try: 748 | _, fingerprint, _ = line.split(" ", 2) 749 | fingerprints.append(fingerprint) 750 | except ValueError: 751 | continue 752 | return fingerprints 753 | 754 | @staticmethod 755 | def GetPublicKeyFingerprint(key): 756 | """Returns the fingerprint of a public key as a string.""" 757 | retval, stdout = AgentManager.RunShellCommand( 758 | "ssh-keygen -l -f {0} |tr -s ' '".format(key)) 759 | if retval: 760 | return None 761 | 762 | try: 763 | _, fingerprint, _ = stdout.decode("utf-8").split(" ", 2) 764 | except ValueError: 765 | return None 766 | return fingerprint 767 | 768 | @staticmethod 769 | def GetAgentFile(path, identity): 770 | """Returns the path to an agent config file. 771 | 772 | Args: 773 | path: string, the path where agent config files are kept. 774 | identity: string, identity for which to load the agent. 775 | 776 | Returns: 777 | string, path to the agent file. 778 | """ 779 | # Create the paths, if they do not exist yet. 780 | try: 781 | os.makedirs(path, 0o700) 782 | except OSError as e: 783 | if e.errno != errno.EEXIST: 784 | raise OSError( 785 | "Cannot create agents directory, try manually with " 786 | "'mkdir -p {0}'".format(path)) 787 | 788 | # Use the hostname as part of the path just in case this is on NFS. 789 | agentfile = os.path.join( 790 | path, "agent-{0}-{1}".format(identity, socket.gethostname())) 791 | if os.access(agentfile, os.R_OK) and AgentManager.IsAgentFileValid(agentfile): 792 | print("Agent for identity {0} ready".format(identity), file=sys.stderr, 793 | loglevel=LOG_DEBUG) 794 | return agentfile 795 | 796 | print("Preparing new agent for identity {0}".format(identity), file=sys.stderr, 797 | loglevel=LOG_DEBUG) 798 | retval = subprocess.call( 799 | ["/usr/bin/env", "-i", "/bin/sh", "-c", "ssh-agent > {0}".format(agentfile)]) 800 | return agentfile 801 | 802 | @staticmethod 803 | def IsAgentFileValid(agentfile): 804 | """Returns true if the specified agentfile refers to a running agent.""" 805 | retval, output = AgentManager.RunShellCommandInAgent( 806 | agentfile, "ssh-add -l >/dev/null 2>/dev/null") 807 | if retval & 0xff not in [0, 1]: 808 | print("Agent in {0} not running".format(agentfile), file=sys.stderr, 809 | loglevel=LOG_DEBUG) 810 | return False 811 | return True 812 | 813 | @staticmethod 814 | def RunShellCommand(command): 815 | """Runs a shell command, returns (status, stdout), (int, string).""" 816 | command = ["/bin/sh", "-c", command] 817 | process = subprocess.Popen(command, stdout=subprocess.PIPE) 818 | stdout, stderr = process.communicate() 819 | return process.wait(), stdout 820 | 821 | @staticmethod 822 | def RunShellCommandInAgent(agentfile, command, stdin=None, stdout=subprocess.PIPE): 823 | """Runs a shell command with an agent configured in the environment.""" 824 | command = ["/bin/sh", "-c", 825 | ". {0} >/dev/null 2>/dev/null; {1}".format(agentfile, command)] 826 | process = subprocess.Popen(command, stdin=stdin, stdout=stdout) 827 | stdout, stderr = process.communicate() 828 | return process.wait(), stdout 829 | 830 | @staticmethod 831 | def EscapeShellArguments(argv): 832 | """Escapes all arguments to the shell, returns a string.""" 833 | escaped = [] 834 | for arg in argv: 835 | escaped.append("'{0}'".format(arg.replace("'", "'\"'\"'"))) 836 | return " ".join(escaped) 837 | 838 | def GetShellArgs(self): 839 | """Returns the flags to be passed to the shell to run a command.""" 840 | shell_args = "-c" 841 | if ShouldPrint(self.config, LOG_DEBUG): 842 | shell_args = "-xc" 843 | return shell_args 844 | 845 | def RunSSH(self, argv): 846 | """Execs ssh with the specified arguments.""" 847 | additional_flags = self.config.Get("SSH_OPTIONS").get( 848 | self.identity, self.config.Get("SSH_DEFAULT_OPTIONS")) 849 | if (self.ssh_config): 850 | additional_flags += " -F {0}".format(self.ssh_config) 851 | 852 | command = [ 853 | "/bin/sh", self.GetShellArgs(), 854 | ". {0} >/dev/null 2>/dev/null; exec {1} {2} {3}".format( 855 | self.agent_file, self.config.Get("BINARY_SSH"), 856 | additional_flags, self.EscapeShellArguments(argv))] 857 | os.execv("/bin/sh", command) 858 | 859 | def AutodetectBinary(argv, config): 860 | """Detects the correct binary to run and sets BINARY_SSH accordingly, 861 | if it is not already set.""" 862 | # If BINARY_SSH is set by the user, respect that and do nothing. 863 | if config.Get("BINARY_SSH"): 864 | print("Will run '{0}' as ssh binary - set by user via BINARY_SSH" 865 | .format(config.Get("BINARY_SSH")), loglevel=LOG_DEBUG) 866 | return 867 | 868 | # If BINARY_DIR is set, look for the binary in this directory. 869 | runtime_name = argv[0] 870 | if config.Get("BINARY_DIR"): 871 | binary_name = os.path.basename(runtime_name) 872 | binary_path = os.path.join(config.Get("BINARY_DIR"), binary_name) 873 | if not os.path.isfile(binary_path) or not os.access(binary_path, os.X_OK): 874 | binary_path = os.path.join(config.Get("BINARY_DIR"), "ssh") 875 | 876 | config.Set("BINARY_SSH", binary_path) 877 | print("Will run '{0}' as ssh binary - detected based on BINARY_DIR" 878 | .format(config.Get("BINARY_SSH")), loglevel=LOG_DEBUG) 879 | return 880 | 881 | # argv[0] could be pretty much anything the caller decides to set 882 | # it to: an absolute path, a relative path (common in older systems), 883 | # or even something entirely unrelated. 884 | # 885 | # Similar is true for __file__, which might even represent a location 886 | # that is entirely unrelated to how ssh-ident was found. 887 | # 888 | # Consider also that there might be symlinks / hard links involved. 889 | # 890 | # The logic here is pretty straightforward: 891 | # - Try to eliminate the path of ssh-ident from PATH. 892 | # - Search for a binary with the same name of ssh-ident to run. 893 | # 894 | # If this fails, we may end up in some sort of loop, where ssh-ident 895 | # tries to run itself. This should normally be detected later on, 896 | # where the code checks for the next binary to run. 897 | # 898 | # Note also that users may not be relying on having ssh-ident in the 899 | # PATH at all - for example, with "rsync -e '/path/to/ssh-ident' ..." 900 | binary_name = os.path.basename(runtime_name) 901 | ssh_ident_path = "" 902 | if not os.path.dirname(runtime_name): 903 | message = textwrap.dedent("""\ 904 | argv[0] ("{0}") is a relative path. This means that ssh-ident does 905 | not know its own directory, and can't exclude it from searching it 906 | in $PATH: 907 | 908 | PATH="{1}" 909 | 910 | This may result in a loop, with 'ssh-ident' trying to run itself. 911 | It is recommended that you set BINARY_SSH, BINARY_DIR, or run 912 | ssh-ident differently to prevent this problem.""") 913 | print(message.format(runtime_name, os.environ['PATH']), 914 | loglevel=LOG_INFO) 915 | else: 916 | ssh_ident_path = os.path.abspath(os.path.dirname(runtime_name)) 917 | 918 | # Remove the path containing the ssh-ident symlink (or whatever) from 919 | # the search path, so we do not cause an infinite loop. 920 | # Note that: 921 | # - paths in PATH may be not-normalized, example: "/usr/bin/../foo", 922 | # or "/opt/scripts///". Normalize them before comparison. 923 | # - paths in PATH may be repeated multiple times. We have to exclude 924 | # all instances of the ssh-ident path. 925 | normalized_path = [ 926 | os.path.normpath(p) for p in os.environ['PATH'].split(os.pathsep)] 927 | search_path = os.pathsep.join([ 928 | p for p in normalized_path if p != ssh_ident_path]) 929 | 930 | # Find an executable with the desired name. 931 | binary_path = shutil.which(binary_name, path=search_path) 932 | if not binary_path: 933 | # Nothing found. Try to find something named 'ssh'. 934 | binary_path = shutil.which('ssh') 935 | 936 | if binary_path: 937 | config.Set("BINARY_SSH", binary_path) 938 | print("Will run '{0}' as ssh binary - detected from argv[0] and $PATH" 939 | .format(config.Get("BINARY_SSH")), loglevel=LOG_DEBUG) 940 | else: 941 | message = textwrap.dedent("""\ 942 | ssh-ident was invoked in place of the binary {0} (determined from argv[0]). 943 | Neither this binary nor 'ssh' could be found in $PATH. 944 | 945 | PATH="{1}" 946 | 947 | You need to adjust your setup for ssh-ident to work: consider setting 948 | BINARY_SSH or BINARY_DIR in your config, or running ssh-ident some 949 | other way.""") 950 | print(message.format(argv[0], os.environ['PATH']), loglevel=LOG_ERROR) 951 | sys.exit(255) 952 | 953 | def ParseCommandLine(argv, config): 954 | """Parses the command line parameters in argv 955 | and modifies config accordingly.""" 956 | # This function may need a lot of refactoring if it is ever used for more 957 | # than checking for BatchMode for OpenSSH... 958 | binary = os.path.basename(config.Get("BINARY_SSH")) 959 | if binary == 'ssh' or binary == 'scp': 960 | # OpenSSH accepts -o Options as well as -oOption, 961 | # so let's convert argv to the latter form first 962 | i = iter(argv) 963 | argv = [p+next(i, '') if p == '-o' else p for p in i] 964 | # OpenSSH accepts 'Option=yes' and 'Option yes', 'true' instead of 'yes' 965 | # and treats everything case-insensitive 966 | # if an option is given multiple times, 967 | # OpenSSH considers the first occurrence only 968 | re_batchmode = re.compile(r"-oBatchMode[= ](yes|true)", re.IGNORECASE) 969 | re_nobatchmode = re.compile(r"-oBatchMode[= ](no|false)", re.IGNORECASE) 970 | for p in argv: 971 | if re.match(re_batchmode, p): 972 | config.Set("SSH_BATCH_MODE", True) 973 | break 974 | elif re.match(re_nobatchmode, p): 975 | config.Set("SSH_BATCH_MODE", False) 976 | break 977 | 978 | def main(argv): 979 | # Replace stdout and stderr with /dev/tty, so we don't mess up with scripts 980 | # that use ssh in case we error out or similar. 981 | try: 982 | sys.stdout = open("/dev/tty", "w") 983 | sys.stderr = open("/dev/tty", "w") 984 | except IOError: 985 | pass 986 | 987 | config = Config().Load() 988 | # overwrite python's print function with the wrapper SshIdentPrint 989 | global print 990 | print = SshIdentPrint(config) 991 | 992 | AutodetectBinary(argv, config) 993 | # Check that BINARY_SSH is not ssh-ident. 994 | # This can happen if the user sets a binary name only (e.g. 'scp') and a 995 | # symlink with the same name was set up. 996 | # Note that this relies on argv[0] being set sensibly by the caller, 997 | # which is not always the case. argv[0] may also just have the binary 998 | # name if found in a path. 999 | binary_path = os.path.realpath( 1000 | shutil.which(config.Get("BINARY_SSH"))) 1001 | ssh_ident_path = os.path.realpath( 1002 | shutil.which(argv[0])) 1003 | if binary_path == ssh_ident_path: 1004 | message = textwrap.dedent("""\ 1005 | ssh-ident found '{0}' as the next command to run. 1006 | Based on argv[0] ({1}), it seems like this will create a 1007 | loop. 1008 | 1009 | Please use BINARY_SSH, BINARY_DIR, or change the way 1010 | ssh-ident is invoked (eg, a different argv[0]) to make 1011 | it work correctly.""") 1012 | print(message.format(config.Get("BINARY_SSH"), argv[0]), loglevel=LOG_ERROR) 1013 | sys.exit(255) 1014 | ParseCommandLine(argv, config) 1015 | identity = FindIdentity(argv, config) 1016 | keys = FindKeys(identity, config) 1017 | sshconfig = FindSSHConfig(identity, config) 1018 | agent = AgentManager(identity, sshconfig, config) 1019 | 1020 | if not config.Get("SSH_BATCH_MODE"): 1021 | # do not load keys in BatchMode 1022 | agent.LoadUnloadedKeys(keys) 1023 | return agent.RunSSH(argv[1:]) 1024 | 1025 | if __name__ == "__main__": 1026 | try: 1027 | sys.exit(main(sys.argv)) 1028 | except KeyboardInterrupt: 1029 | print("Goodbye", file=sys.stderr, loglevel=LOG_DEBUG) 1030 | --------------------------------------------------------------------------------