├── .gitignore ├── LICENSE ├── docs ├── workflow.md └── annotated_config.md ├── README.md ├── test_cmc └── cmc /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Timid Robot Zehta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /docs/workflow.md: -------------------------------------------------------------------------------- 1 | # Workflow 2 | 3 | SSH is a powerful tool. When configured correctly it should not only provide 4 | excellent security, it should also make your work *easier* and 5 | *more convenient*. 6 | 7 | 8 | ## Assumptions 9 | 10 | 1. You're using macOS 11 | - This is not a requirement. The `cmc` script should work on any \*nix. 12 | 2. You have already created a SSH key pair and added the secret key to your 13 | Mac keychain. 14 | - Test to see currently available keys: 15 | 1. `ssh-add -L` 16 | - SSH key pair creation example: 17 | 1. `ssh-keygen -b 4096 -C USERNAME@COMPUTER_DESC` 18 | 2. `ssh-add -K` 19 | 3. Your name is Arthur Dent 20 | - Your username on your Mac laptop is `arthurdent` 21 | - Your username on remote systems is `arthur` 22 | 4. Only the `~/.ssh/config` on your laptop will ever need to be edited. 23 | 5. Three hypothetical hosts (see below) 24 | 25 | 26 | ### Example `~/.ssh/config` Configuration 27 | 28 | ``` 29 | # insecure 30 | Host insecure insecure.example.com 31 | HostName insecure.example.com 32 | 33 | # bastion 34 | Host bastion bastion.example.com 35 | HostName bastion.example.com 36 | ControlPersist 8h 37 | 38 | # production 39 | Host prod production prod*.example.com 40 | HostName production.example.com 41 | ControlPersist 2h 42 | ProxyCommand ssh -q bastion nc -w30 %h %p 43 | 44 | # global defaults 45 | Host * 46 | ControlMaster auto 47 | ControlPath ~/.ssh/cp_%r_%h 48 | ControlPersist 5m 49 | ServerAliveCountMax 60 50 | ServerAliveInterval 30 51 | TCPKeepAlive no 52 | User arthur 53 | ``` 54 | 55 | Please also see the annotated ssh configuration example with explanations: 56 | [`annotated_config.md`](annotated_config.md). 57 | 58 | 59 | ## Workflow 60 | 61 | 1. Connect "directly" to hosts behind the firewall using bastion as a proxy 62 | - `ssh prod` 63 | - Automatically connects to `bastion` and proxies through it. 64 | 2. Realize you need to completely reconnect for some reason (ex. you made an 65 | error in your gpg-agent configuration). 66 | 3. List current connections with `cmc -l`. 67 | 4. Close impacted connections with 68 | - `cmc -x bastion` (which will automatically close the connection to `prod`) 69 | - or `cmc -X` to close all active connections 70 | 71 | 72 | ## References 73 | 74 | - **[ssh_config(5)][mansshconfig]** 75 | 76 | [mansshconfig]:http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cmc 2 | 3 | ControlMaster Controller - Eases management of SSH ControlMaster connections 4 | 5 | 6 | ## Overview 7 | 8 | `cmc` makes SSH ControlMaster sessions easy. SSH ControlMaster sessions have 9 | the following benefits: 10 | - When using a SSH Bastion, you will only be prompted for (two-factor) 11 | authentication once. 12 | - Hosts behind the SSH Bastion can be accessed "directly" by proxying through 13 | the SSH Bastion (see workflow_). 14 | - Sessions to or through ControlMaster hosts do not need to create a new 15 | connection (SSH will be faster for most tasks) 16 | 17 | 18 | ## Script Help 19 | 20 | ``` 21 | Usage: cmc [ -c HOST | -x HOST ] 22 | cmc [ -l | -X ] 23 | cmc -h 24 | 25 | ControlMaster Controller - Eases management of SSH ControlMaster connections 26 | 27 | Options: 28 | -h show this help message and exit 29 | -c HOST check HOST ControlMaster connection status (may be specified more 30 | than once) 31 | -d print debug information 32 | -l list all active ControlMaster connection sockets 33 | -x HOST exit ControlMaster session (may be specified more than once) 34 | -X exit all ControlMaster connections with sockets 35 | 36 | Notes: 37 | - Any unused sockets in ControlPath are removed with -l and -X 38 | ``` 39 | (output of `cmc -h`) 40 | 41 | 42 | ## Install 43 | 44 | 1. [Install Homebrew][brewinstall] -- The missing package manager for macOS 45 | 2. Add the "tap": 46 | ```shell 47 | brew tap TimidRobot/tap 48 | ``` 49 | 3. Install `cmc`: 50 | ``` 51 | brew install cmc 52 | ``` 53 | 54 | Alternatively, since `cmc` is a bash script without esoteric dependencies, 55 | you can simply download it and ensure it is in your `PATH`. 56 | 57 | If this utility is helpful for you, please star this project so that it can 58 | eventually be included in Homebrew proper. Thank you! 59 | 60 | [brewinstall]: http://brew.sh/#install 61 | 62 | 63 | ## Documentation 64 | 65 | 66 | ### ControlPersist 67 | 68 | Prior to the addition of `ControlPersist` in [OpenSSH 5.6][openssh56], this 69 | utility was needed. However it still convenient to be able to quickly manage 70 | ControlMaster connections. 71 | 72 | For more information on `ControlPersist` see [ssh_config(5)][mansshconfig]. 73 | 74 | [openssh56]: https://www.openssh.com/txt/release-5.6 75 | [mansshconfig]: http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5 76 | 77 | 78 | ### Workflow 79 | 80 | See [`docs/workflow`][workflow]. 81 | 82 | [workflow]: docs/workflow.md 83 | 84 | 85 | ### Annotated SSH Configuration 86 | 87 | See [`docs/annotated_config`][annotated]. 88 | 89 | [annotated]: docs/annotated_config.md 90 | 91 | 92 | ### Related 93 | 94 | - any \*nix Operating Systems (including Linux and macOS): 95 | - **[solo-agent][soloagent]**: Enable discrete SSH Agents to avoid leaking 96 | access across hosts 97 | - macOS only 98 | - [mac-ssh-confirm][confirm]: Protect against SSH Agent Hijacking on macOS 99 | with the ability to confirm agent identities prior to each use 100 | 101 | [gacli]: https://github.com/TimidRobot/gacli 102 | [soloagent]: https://github.com/TimidRobot/solo-agent 103 | [confirm]: https://github.com/TimidRobot/mac-ssh-confirm 104 | 105 | 106 | ## Requirements 107 | 108 | - any \*nix Operating System (including Linux and macOS) with: 109 | - core utilities (`awk`, `find`, `grep`, `ps`, and `sed`) 110 | - GNU Bourne-Again Shell 3.0 or later (`bash`) 111 | - OpenSSH 5.6 or later (`ssh`) 112 | - For OpenSSH versions between 4.0 and 5.6, try [cmc 1.0.3][cmc103] 113 | 114 | [cmc103]:https://github.com/TimidRobot/cmc/tree/1.0.3 115 | 116 | 117 | ## Development 118 | 119 | Run `./test_cmc ${TESTHOST}` prior to signing a new release. 120 | 121 | Thank you: 122 | - [shellcheck][shellcheck] - a static analysis tool for shell 123 | 124 | [shellcheck]: https://github.com/koalaman/shellcheck 125 | 126 | 127 | ## License 128 | 129 | - [`LICENSE`](LICENSE) (Expat/[MIT][mit] License) 130 | 131 | [mit]: http://www.opensource.org/licenses/MIT "The MIT License | Open Source Initiative" 132 | -------------------------------------------------------------------------------- /test_cmc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit 3 | set -o errtrace 4 | set -o nounset 5 | 6 | # shellcheck disable=SC2154 7 | trap '_es=${?}; 8 | printf "${0}: line ${LINENO}: \"${BASH_COMMAND}\""; 9 | printf " exited with a status of ${_es}\n"; 10 | exit ${_es}' ERR 11 | 12 | # https://en.wikipedia.org/wiki/ANSI_escape_code 13 | E0="$(printf "\e[0m")" # reset 14 | E30="$(printf "\033[30m")" # black foreground 15 | E107="$(printf "\033[107m")" # bright white background 16 | 17 | 18 | print_header() { 19 | # Print 80 character wide black on white heading 20 | printf "${E30}${E107} %-70s$(date '+%T') ${E0}\n" "${@}" 21 | echo -n "${E30}${E107}" 22 | for _ in {1..80} 23 | do 24 | echo -n '=' 25 | done 26 | echo "${E0}" 27 | } 28 | 29 | 30 | TESTHOST=${1:-} 31 | 32 | if [[ -z "${TESTHOST}" ]] 33 | then 34 | echo 'ERROR: the TESTHOST argument is required' 1>&2 35 | exit 1 36 | fi 37 | 38 | print_header 'Show Debug information' 39 | ./cmc -d 40 | echo 41 | echo 42 | 43 | 44 | print_header 'Verify syntax' 45 | shellcheck cmc 46 | echo 'no syntax issues identified' 47 | echo 48 | echo 49 | 50 | 51 | HEAD="Verify \`-c ${TESTHOST}\` check HOST ControlMaster connection status" 52 | print_header "${HEAD}" 53 | echo "1. Establish a connection to ${TESTHOST}" 54 | ssh -o ClearAllForwardings=yes -o ControlPersist=5m -o VisualHostKey=no \ 55 | "${TESTHOST}" exit 0 56 | echo 57 | echo "2. Check ${TESTHOST} with active socket" 58 | ./cmc -c "${TESTHOST}" \ 59 | | sed -e's/^/ /' 60 | echo 61 | PID=$(./cmc -c "${TESTHOST}" | awk '/pid:/ {print $2}') 62 | echo "3. Send ALRM signal to PID ${PID} leave a stale socket" 63 | kill -14 "${PID}" 64 | echo 65 | echo "4. Check ${TESTHOST} with stale socket" 66 | ./cmc -c "${TESTHOST}" 2>&1 \ 67 | | sed -e's/^/ /' 68 | echo 69 | echo 70 | 71 | 72 | # shellcheck disable=SC2016 73 | print_header 'Verify `-l` list all active ControlMaster connection sockets' 74 | echo "1. Establish a connection to ${TESTHOST}" 75 | ssh -o ClearAllForwardings=yes -o ControlPersist=5m -o VisualHostKey=no \ 76 | "${TESTHOST}" exit 0 77 | echo 78 | echo '2. List all with active socket' 79 | ./cmc -l \ 80 | | sed -e's/^/ /' 81 | echo 82 | PID=$(./cmc -c "${TESTHOST}" | awk '/pid:/ {print $2}') 83 | echo "3. Send ALRM signal to PID ${PID} leave a stale socket" 84 | kill -14 "${PID}" 85 | echo 86 | echo '4. List all with stale socket' 87 | ./cmc -l 2>&1 \ 88 | | sed -e's/^/ /' 89 | echo 90 | echo 91 | 92 | 93 | print_header "Verify \`-x ${TESTHOST}\` exit ControlMaster session" 94 | echo "1. Establish a connection to ${TESTHOST}" 95 | ssh -o ClearAllForwardings=yes -o ControlPersist=5m -o VisualHostKey=no \ 96 | "${TESTHOST}" exit 0 97 | echo 98 | echo "2. Exit ${TESTHOST} with active socket" 99 | ./cmc -x "${TESTHOST}" \ 100 | | sed -e's/^/ /' 101 | echo 102 | echo "3. Establish a connection to ${TESTHOST}" 103 | ssh -o ClearAllForwardings=yes -o ControlPersist=5m -o VisualHostKey=no \ 104 | "${TESTHOST}" exit 0 105 | echo 106 | PID=$(./cmc -c "${TESTHOST}" | awk '/pid:/ {print $2}') 107 | echo "4. Send ALRM signal to PID ${PID} leave a stale socket" 108 | kill -14 "${PID}" 109 | echo 110 | echo "5. Exit ${TESTHOST} with stale socket" 111 | ./cmc -x "${TESTHOST}" 2>&1 \ 112 | | sed -e's/^/ /' 113 | echo 114 | echo 115 | 116 | 117 | # shellcheck disable=SC2016 118 | print_header 'Verify `-X` exit all ControlMaster connections with sockets' 119 | echo "1. Establish a connection to ${TESTHOST}" 120 | ssh -o ClearAllForwardings=yes -o ControlPersist=5m -o VisualHostKey=no \ 121 | "${TESTHOST}" exit 0 122 | echo 123 | echo '2. Exit all with active socket' 124 | ./cmc -X \ 125 | | sed -e's/^/ /' 126 | echo 127 | echo "3. Establish a connection to ${TESTHOST}" 128 | ssh -o ClearAllForwardings=yes -o ControlPersist=5m -o VisualHostKey=no \ 129 | "${TESTHOST}" exit 0 130 | echo 131 | PID=$(./cmc -c "${TESTHOST}" | awk '/pid:/ {print $2}') 132 | echo "4. Send ALRM signal to PID ${PID} leave a stale socket" 133 | kill -14 "${PID}" 134 | echo 135 | echo '5. Exit all with stale socket' 136 | ./cmc -X 2>&1 \ 137 | | sed -e's/^/ /' 138 | echo 139 | -------------------------------------------------------------------------------- /docs/annotated_config.md: -------------------------------------------------------------------------------- 1 | # Annotated SSH Configuration 2 | 3 | Please also see the workflow documentation: [`workflow.md`](workflow.md). 4 | 5 | 6 | ## Example SSH Config 7 | 8 | ``` 9 | # insecure 10 | Host insecure insecure.example.com 11 | HostName insecure.example.com 12 | 13 | # bastion 14 | Host bastion bastion.example.com 15 | HostName bastion.example.com 16 | ControlPersist 8h 17 | 18 | # production 19 | Host prod production prod*.example.com 20 | HostName production.example.com 21 | ControlPersist 2h 22 | ProxyCommand ssh -q bastion nc -w30 %h %p 23 | 24 | # global defaults 25 | Host * 26 | ControlMaster auto 27 | ControlPath ~/.ssh/cp_%r_%h 28 | ControlPersist 5m 29 | ServerAliveCountMax 60 30 | ServerAliveInterval 30 31 | TCPKeepAlive no 32 | User arthur 33 | ``` 34 | 35 | 36 | ### Section: `# insecure` 37 | 38 | This section is for a server on the Internet that we think is insecure (we 39 | do not trust the administrators--those with root access). 40 | 41 | ``` 42 | # insecure 43 | Host insecure insecure.example.com 44 | HostName insecure.example.com 45 | ``` 46 | 47 | 1. `# insecure` is a comment. It helps provide context for for the 48 | line that follows it. 49 | 2. `Host insecure insecure.example.com` indicates the host patterns that the 50 | subsequent parameters apply to. All of the following will work to connect 51 | to the configured HostName: 52 | - `ssh insecure` 53 | - `ssh insecure.example.com` 54 | 3. `HostName insecure.example.com` specifies the real host name to log into. 55 | 56 | 57 | ### Section: `# bastion` 58 | 59 | This section is for a server on the Internet that acts as a SSH bastion. It 60 | provides access to servers behind a firewall. :: 61 | 62 | ``` 63 | # bastion 64 | Host bastion bastion.example.com 65 | HostName bastion.example.com 66 | ControlPersist 8h 67 | ``` 68 | 69 | 1. `# bastion` is a comment. It helps provide context for for the 70 | line that follows it. 71 | 2. `Host bastion bastion.example.com` indicates the host patterns that the 72 | subsequent parameters apply to. All of the following will work to connect 73 | to the configured HostName: 74 | - `ssh bastion` 75 | - `ssh bastion.example.com` 76 | 3. `HostName bastion.example.com` specifies the real host name to log into. 77 | 4. `ControlPersist 8h` specifies that the master connection should remain open 78 | and idle in the background for up to 8 hours. This is especially convenient 79 | when the bastion server requires Multifactor authentication (MFA). 80 | - `ControlPersist` is available as of OpenSSH 5.6. For previous versions, 81 | simply omit it. 82 | 83 | 84 | ### Section: `# production` 85 | 86 | This section is for a server on the Internet that acts as a SSH production. It 87 | provides access to servers behind a firewall. 88 | 89 | ``` 90 | # production 91 | Host prod production prod*.example.com 92 | HostName production.example.com 93 | ControlPersist 2h 94 | ProxyJump bastion 95 | ``` 96 | 97 | 1. `# production` is a comment. It helps provide context for for the 98 | line that follows it. 99 | 2. `Host prod production prod*.example.com` indicates the host patterns that 100 | the subsequent parameters apply to. All of the following will work to 101 | connect to the configured HostName: 102 | - `ssh prod` 103 | - `ssh production` 104 | - `ssh prod.example.com` 105 | - `ssh production.example.com` 106 | 3. `HostName production.example.com` specifies the real host name to log into. 107 | 4. `ControlPersist 2h` specifies that the master connection should remain open 108 | and idle in the background for up to 2 hours. 109 | - `ControlPersist` is available as of OpenSSH 5.6. For previous versions, 110 | simply omit it. 111 | 5. `ProxyJump bastion` specifies that SSH host to proxy connections through. 112 | Any SSH client (ex. ssh command line, git, Transmit app) will see the 113 | production session as a single connection. It just works! 114 | - `ProxyJump` is available as of OpenSSH 7.3 115 | - For OpenSSH versions 5.4 through 7.2 use: 116 | ``` 117 | ProxyCommand ssh bastion -W %h:%p 118 | ``` 119 | - For OpenSSH versions 5.3 and below use: 120 | ``` 121 | ProxyCommand ssh -q bastion nc -w30 %h %pi 122 | ``` 123 | 124 | 125 | ### Section: `# global defaults` 126 | 127 | The global defaults for all hosts is specified last. Its parameters apply if 128 | they are not previously defined (which is why it should be the *last* section 129 | of your SSH config). 130 | 131 | ``` 132 | # global defaults 133 | Host * 134 | ControlMaster auto 135 | ControlPath ~/.ssh/cp_%r_%h 136 | ControlPersist 5m 137 | ServerAliveCountMax 60 138 | ServerAliveInterval 30 139 | TCPKeepAlive no 140 | User arthur 141 | ``` 142 | 143 | 1. `# global defaults` is a comment. It helps provide context for for the 144 | line that follows it. 145 | 2. `Host *` indicates this is the global defaults section. 146 | 3. `ControlPath ~/.ssh/cp_%r_%h` supports the ControlMaster parameter. The 147 | path given here supports longer host names which can otherwise cause 148 | issues. 149 | 4. `ControlPersist 5m` specifies that the master connection should remain open 150 | and idle in the background for up to 5 minutes. This will speedup version 151 | control commands while also being a good conservative default. 152 | - `ControlPersist` is available as of OpenSSH 5.6. For previous versions, 153 | simply omit it. 154 | 5. `ServerAliveCountMax 60` helps ensure robust proxied sessions. 155 | 6. `ServerAliveInterval 30` helps ensure robust proxied sessions. 156 | - A `ServerAliveInterval` of 30s combined with a `ServerAliveCountMax` of 157 | 60 will result in disconnections of unresponsive clients after half an 158 | hour. 159 | - The relatively short `ClientAliveInterval` should ensure aggressive TTLs 160 | do not severe connections. The larger `ClientAliveCountMax` should allow 161 | brief interruptions without disrupting work. 162 | 7. `TCPKeepAlive no` allows connections to weather short network outages 163 | (especially useful when connected via WiFi). 164 | 8. `User arthur` specifies the user to log in as (remember, in our example 165 | the local username is arthurdent). 166 | 167 | Additionally, the following defaults are important. The parameter is not in 168 | this section because the OpenSSH default value is appropriate. It should be 169 | acknowledged so that it is not unintentionally superseded by a configured 170 | parameter: 171 | 172 | - `ForwardAgent no` specifies that the authentication agent will **not** be 173 | forwarded. This prevents administrators on untrusted remote servers from 174 | masquerading as you on *any* system on which you have your SSH public key. 175 | See [SSH Agent Hijacking][hijacking] for more information. 176 | 177 | 178 | ## References 179 | 180 | - **[ssh_config(5)][mansshconfig]** 181 | - [SSH Agent Hijacking][hijacking] 182 | 183 | [mansshconfig]:http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5 184 | [hijacking]:http://www.clockwork.net/blog/2012/09/28/602/ssh_agent_hijacking 185 | -------------------------------------------------------------------------------- /cmc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # ControlMaster Controller - Eases management of SSH ControlMaster connections 4 | # 5 | # Notes 6 | # • `kill -sigalrm SESSION_PID` will result in SSH leaving a stale socket 7 | # 8 | #### SETUP #################################################################### 9 | set -o errexit 10 | set -o errtrace 11 | set -o nounset 12 | 13 | trap '_es=${?}; 14 | printf "${0}: line ${LINENO}: \"${BASH_COMMAND}\""; 15 | printf " exited with a status of ${_es}\n"; 16 | exit ${_es}' ERR 17 | 18 | #### Variables 19 | declare -i ES 20 | PROG="${0##*/}" 21 | USAGE="\ 22 | Usage: ${PROG} [ -c HOST | -x HOST ] 23 | ${PROG} [ -l | -X ] 24 | ${PROG} -h 25 | 26 | ControlMaster Controller - Eases management of SSH ControlMaster connections 27 | 28 | Options: 29 | -h show this help message and exit 30 | -c HOST check HOST ControlMaster connection status (may be specified more 31 | than once) 32 | -d print debug information 33 | -l list all active ControlMaster connection sockets 34 | -x HOST exit ControlMaster session (may be specified more than once) 35 | -X exit all ControlMaster connections with sockets 36 | 37 | Notes: 38 | - Any unused sockets in ControlPath are removed with -l and -X 39 | " 40 | ES=0 41 | HOSTS=() 42 | SSH_CONFIG=~/.ssh/config 43 | # Colors 44 | DKG=$(printf '\e[90m') 45 | RED=$(printf '\e[91m') 46 | RST=$(printf '\e[0m') 47 | WHT=$(printf '\e[97m') 48 | YLW=$(printf '\e[93m') 49 | 50 | 51 | #### FUNCTIONS ################################################################ 52 | 53 | 54 | action_check_set() { 55 | # check action and set provided there is no conflict 56 | local _action=${1} 57 | local _opt=${2} 58 | if [[ -n "${ACTION:-}" ]] && [[ "${ACTION}" != "${_action}" ]] 59 | then 60 | local _msg="more than one action option ('${OPT}' and '${_opt}')" 61 | local _msg="${_msg} specified" 62 | error_exit "${_msg}" 2 63 | else 64 | ACTION=${_action} 65 | OPT=${_opt} 66 | fi 67 | return 0 68 | } 69 | 70 | 71 | config_control_path() { 72 | # Extract ControlPath from SSH config 73 | local _result _result_count 74 | _result=$(awk \ 75 | 'tolower($1) ~ /^controlpath$/ { 76 | if ( tolower($2) !~ /^none$/ ) { 77 | print $2 78 | } 79 | }' ${SSH_CONFIG}) 80 | if [[ -n "${_result}" ]] 81 | then 82 | # Only a single ControlPath should be specified 83 | _result_count=$(echo "${_result}" | sort -u | wc -l) 84 | if (( _result_count > 1 )) 85 | then 86 | error_exit "multiple ControlPaths specified: ${_result}" 87 | fi 88 | # Create Path glob from ControlPath 89 | echo "${_result}" 90 | return 0 91 | else 92 | error_exit "no ControlPath defined in '${SSH_CONFIG}'" 93 | fi 94 | } 95 | 96 | 97 | error_exit() { 98 | # Display error message and exit 99 | declare -i _es 100 | local _msg 101 | _msg="${1}" 102 | _es="${2:-1}" 103 | echo -e "${RED}ERROR: ${_msg}${RST}" 1>&2 104 | exit "${_es}" 105 | } 106 | 107 | 108 | help_print() { 109 | # Print help/usage, then exit (incorrect usage should exit 2) 110 | declare -i _es 111 | _es="${1:-0}" 112 | echo "${USAGE}" 113 | exit "${_es}" 114 | } 115 | 116 | 117 | host_check_session() { 118 | declare -i _es 119 | local _check _cmd _host _pid _socket _status 120 | _host=${1} 121 | _socket=${2:-} 122 | if _check=$(session_check "${_host}" || exit ${?}) 123 | then 124 | echo "${WHT}${_host}${RST}" 125 | _pid="${_check%)}" 126 | _pid="${_pid##*=}" 127 | echo " ${DKG}pid:${RST} ${_pid}" 128 | _start="$(ps -p "${_pid}" -o start=)" 129 | _start="$(strip_clean "${_start}")" 130 | _start="${_start# }" 131 | echo " ${DKG}start:${RST} ${_start}" 132 | _status="${_check% (*}" 133 | echo "${DKG}status:${RST} ${_status}" 134 | _socket="${2:-}" 135 | if [[ -z "${_socket}" ]] 136 | then 137 | _cmd="$(ps -p "${_pid}" -o command=)" 138 | _cmd="$(strip_clean "${_cmd}")" 139 | _socket="${_cmd#* }" 140 | _socket="${_socket% *}" 141 | fi 142 | _socket="${_socket/${HOME}/~}" 143 | echo "${DKG}socket:${RST} ${_socket}" 144 | else 145 | _es=${?} 146 | echo "${_check}" 147 | fi 148 | return "${_es:-0}" 149 | } 150 | 151 | 152 | host_from_socket() { 153 | # Extract host from socket name 154 | local _host _hostname _socket 155 | _socket="${1}" 156 | if [[ "${_socket}" =~ ${HOST_REGEX} ]] 157 | then 158 | _hostname="${BASH_REMATCH[1]}" 159 | _hostname_keyword='^[[:space:]]*hostname[[:space:]]+' 160 | _arg_term='([[:space:]]|$)' 161 | _host="$(awk \ 162 | "tolower(\$0) ~ /^[[:space:]]*host[[:space:]]+/ { 163 | host=\$2 164 | } 165 | tolower(\$0) ~ /${_hostname_keyword}${_hostname}${_arg_term}/ { 166 | print host 167 | }" ${SSH_CONFIG})" 168 | if [[ -z "${_host}" ]] 169 | then 170 | _host="${_hostname}" 171 | fi 172 | echo "${_host}" 173 | else 174 | { 175 | echo "${YLW}WARNING: cannot match host from socket name${RST}" 176 | echo " regex pattern: ${HOST_REGEX}" 177 | echo " socket: ${_socket}" 178 | } 1>&2 179 | echo 'HOSTNAME' 180 | fi 181 | return 0 182 | } 183 | 184 | 185 | print_debug_info() { 186 | # Print debug information (from set_control_path_based_globals) 187 | echo "${DKG} BASH_PATH:${RST} ${BASH}" 188 | echo "${DKG}BASH_VERSION:${RST} $(${BASH} --version | head -n1)" 189 | echo 190 | echo "${DKG}CONTROL_PATH:${RST} ${CONTROL_PATH}" 191 | echo 192 | echo "${DKG}CONTROL_GLOB:${RST} ${CONTROL_GLOB}" 193 | echo "${DKG} HOST_REGEX:${RST} ${HOST_REGEX}" 194 | } 195 | 196 | 197 | session_check() { 198 | # Check ControlMaster connection status 199 | declare -i _es 200 | local _cmd _host _msg _pid _result _socket _start 201 | _host="${1}" 202 | _socket="${2:-}" 203 | _result="$(ssh -O check "${_host}" 2>&1 || exit ${?})" || _es="${?}" 204 | _result="$(strip_clean "${_result}")" 205 | if (( ${_es:-0} != 0 )) 206 | then 207 | if echo "${_result}" | grep -F -q ': line ' 208 | then 209 | echo BREAK 210 | _msg='there appears to be a syntax error in the ssh config:' 211 | error_exit "${_msg}${RST}\n${_result}" 212 | fi 213 | if [[ -z "${_socket}" ]] 214 | then 215 | # Attempt to parse socket from results 216 | _socket="${_result#*(}" 217 | _socket="${_socket%)*}" 218 | fi 219 | if [[ -S "${_socket}" ]] 220 | then 221 | echo CLEAN 222 | socket_clean "${_socket}" 223 | fi 224 | return ${_es} 225 | fi 226 | echo "${_result}" 227 | } 228 | 229 | 230 | session_exit() { 231 | # Exit an existing ControlMaster process 232 | declare -i _es 233 | local _host _result _socket 234 | _host="${1}" 235 | if _result=$(session_check "${_host}" || exit ${?}) 236 | then 237 | : # success, no-op 238 | else 239 | _es=${?} 240 | echo "${_result}" 241 | return ${_es} 242 | fi 243 | echo "${WHT}${_host}${RST}" 244 | echo ' Closing ControlMaster connection' 245 | _result="$(ssh -O exit "${_host}" 2>&1)" 246 | _result=$(strip_clean "${_result}" || true) 247 | echo " ${_result}" 248 | } 249 | 250 | 251 | session_all_exit() { 252 | # Exit ALL ControlMaster with connection sockets in ~/.ssh/ 253 | declare -i _es _estat _i 254 | local _host _result _sockets 255 | _sockets=$(sockets_list) 256 | if [[ -z "${_sockets}" ]] 257 | then 258 | error_exit 'no ControlMaster connection sharing sockets found' 259 | else 260 | _i=0 261 | for _socket in ${_sockets} 262 | do 263 | _estat=0 264 | (( _i > 0 )) && echo 265 | _host=$(host_from_socket "${_socket}") 266 | _result=$(session_exit "${_host}") || _estat=4 267 | (( _estat == 0 )) || _es="${_estat}" 268 | if [[ "${_result}" == 'BREAK' ]] 269 | then 270 | break 271 | elif [[ "${_result}" != 'CLEAN' ]] 272 | then 273 | (( ${_estat:-0} == 0 )) && [[ -n "${_result}" ]] && \ 274 | echo "${_result}" 275 | (( _estat == 0 )) || warn_no_socket "${_host}" 276 | fi 277 | _i+=1 278 | done 279 | fi 280 | return ${_es:-0} 281 | } 282 | 283 | 284 | set_control_path_based_globals() { 285 | # Set global variables based on ControlPath 286 | CONTROL_PATH="$(config_control_path)" 287 | CONTROL_GLOB=${CONTROL_PATH//%?/*} 288 | CONTROL_GLOB=${CONTROL_GLOB/\~/${HOME}} 289 | HOST_REGEX=${CONTROL_PATH//%h/(.*)} 290 | HOST_REGEX=${HOST_REGEX//%?/.*} 291 | HOST_REGEX=${HOST_REGEX//\~/~} 292 | } 293 | 294 | 295 | socket_clean() { 296 | # Remove stale socket 297 | local _socket 298 | _socket="${1}" 299 | { 300 | echo "${YLW}WARNING: stale ControlMaster connection socket${RST}" 301 | echo " Removing: ${_socket}" 302 | } 1>&2 303 | rm -f "${_socket}" 304 | return 0 305 | } 306 | 307 | 308 | sockets_display() { 309 | # List ControlMaster connections sockets found in ~/.ssh 310 | declare -i _es _i 311 | local _host _result _sockets 312 | _sockets=$(sockets_list) 313 | # find sockets 314 | if [[ -z "${_sockets}" ]] 315 | then 316 | echo 'No ControlMaster connection sockets found.' 1>&2 317 | return 0 318 | else 319 | _i=0 320 | for _socket in ${_sockets} 321 | do 322 | (( _i > 0 )) && echo 323 | _host=$(host_from_socket "${_socket}") 324 | _result=$(host_check_session "${_host}" || exit ${?}) 325 | _es=${?} 326 | if [[ "${_result}" == 'BREAK' ]] 327 | then 328 | break 329 | elif [[ "${_result}" != 'CLEAN' ]] 330 | then 331 | echo "${_result}" 332 | fi 333 | _i+=1 334 | done 335 | fi 336 | return "${_es}" 337 | } 338 | 339 | 340 | sockets_list() { 341 | # list ControlMaster connection sockets in ControlPath 342 | local _socket 343 | for _socket in ${CONTROL_GLOB} 344 | do 345 | [[ -S "${_socket}" ]] || continue 346 | echo "${_socket}" 347 | done 348 | return 0 349 | } 350 | 351 | 352 | strip_clean() { 353 | # Strip: 354 | # - all control characters 355 | # - leading whitespace 356 | # - trailing whitespace 357 | echo "${1}" | sed \ 358 | -e's/[[:cntrl:]]*//g' \ 359 | -e's/^[[:space:]]*//' \ 360 | -e's/[[:space:]]*$//' 361 | } 362 | 363 | 364 | warn_no_socket() { 365 | local _host 366 | _host=${1} 367 | echo "${YLW}WARNING: no control socket found for host: ${_host}${RST}" 368 | } 369 | 370 | 371 | #### MAIN ##################################################################### 372 | 373 | 374 | #### Parse options 375 | (( ${#} == 0 )) && help_print 2 376 | while [[ -n "${1:-}" ]] 377 | do 378 | case "${1}" in 379 | -[Hh] | --[Hh][Ee][Ll][Pp] | [Hh][Ee][Ll][Pp] ) 380 | shift 381 | help_print 382 | ;; 383 | -c* ) 384 | flag=${1:0:2} 385 | if (( ${#1} > 2 )) 386 | then 387 | HOSTS+=("${1:2}") 388 | shift 389 | else 390 | HOSTS+=("${2}") 391 | shift 392 | shift 393 | fi 394 | action_check_set 'check' "${flag}" 395 | ;; 396 | -d ) 397 | action_check_set 'debug' "${1}" 398 | shift 399 | ;; 400 | -l ) 401 | action_check_set 'sockets' "${1}" 402 | shift 403 | ;; 404 | -X ) 405 | action_check_set 'exit_all' "${1}" 406 | shift 407 | ;; 408 | -x* ) 409 | flag=${1:0:2} 410 | if (( ${#1} > 2 )) 411 | then 412 | HOSTS+=("${1:2}") 413 | shift 414 | else 415 | HOSTS+=("${2}") 416 | shift 417 | shift 418 | fi 419 | action_check_set 'exit' "${flag}" 420 | ;; 421 | --no-color | -no-color ) 422 | shift 423 | DKG='' 424 | RST='' 425 | RED='' 426 | WHT='' 427 | YLW='' 428 | ;; 429 | * ) 430 | error_exit "invalid option: ${1}" 2 431 | ;; 432 | esac 433 | done 434 | 435 | set_control_path_based_globals 436 | case "${ACTION:-}" in 437 | # Check HOST ControlMaster connection status 438 | 'check' ) 439 | declare -i _i=0 440 | for _host in "${HOSTS[@]}" 441 | do 442 | (( _i > 0 )) && echo 443 | declare -i estat=0 444 | result=$(host_check_session "${_host}" || exit ${?}) || estat=${?} 445 | (( estat == 0 )) || ES="${estat}" 446 | if [[ "${result}" == 'BREAK' ]] 447 | then 448 | break 449 | elif [[ "${result}" != 'CLEAN' ]] 450 | then 451 | [[ -n "${result}" ]] && echo "${result}" 452 | (( estat == 0 )) || warn_no_socket "${_host}" 453 | fi 454 | unset estat result 455 | _i+=1 456 | done 457 | ;; 458 | # Debug - print information 459 | 'debug' ) 460 | print_debug_info 461 | ;; 462 | # Exit HOST ControlMaster connection 463 | 'exit' ) 464 | declare -i _i=0 465 | for _host in "${HOSTS[@]}" 466 | do 467 | (( _i > 0 )) && echo 468 | declare -i estat=0 469 | result=$(session_exit "${_host}" || exit ${?}) || estat=${?} 470 | (( estat == 0 )) || ES="${estat}" 471 | if [[ "${result}" == 'BREAK' ]] 472 | then 473 | break 474 | elif [[ "${result}" != 'CLEAN' ]] 475 | then 476 | [[ -n "${result}" ]] && echo "${result}" 477 | (( estat == 0 )) || warn_no_socket "${_host}" 478 | fi 479 | unset estat result 480 | _i+=1 481 | done 482 | ;; 483 | # Exit all ControlMaster connections with sockets in ~/.ssh/ 484 | 'exit_all' ) 485 | session_all_exit || ES="${?}" 486 | ;; 487 | # List ControlMaster connection sockets in ~/.ssh/ 488 | 'sockets' ) 489 | sockets_display || ES="${?}" 490 | ;; 491 | *) 492 | error_exit 'orphaned argument(s)--no options were specified' 2 493 | ;; 494 | esac 495 | exit "${ES}" 496 | --------------------------------------------------------------------------------