From ac58a0072ec46c39c15b04feb6453baa2a2562ca Mon Sep 17 00:00:00 2001 From: Charles McGarvey Date: Sat, 25 Nov 2017 19:54:15 -0700 Subject: [PATCH 1/1] initial commit --- .travis.yml | 2 + COPYING | 16 +++ Makefile | 16 +++ README.md | 111 +++++++++++++++ docker-connect | 308 ++++++++++++++++++++++++++++++++++++++++++ t/01-basic.sh | 45 ++++++ t/02-socket-errors.sh | 26 ++++ t/03-options.sh | 16 +++ tap.sh | 114 ++++++++++++++++ unittest.sh | 32 +++++ 10 files changed, 686 insertions(+) create mode 100644 .travis.yml create mode 100644 COPYING create mode 100644 Makefile create mode 100644 README.md create mode 100755 docker-connect create mode 100644 t/01-basic.sh create mode 100644 t/02-socket-errors.sh create mode 100644 t/03-options.sh create mode 100644 tap.sh create mode 100644 unittest.sh diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d3ed63b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,2 @@ +language: sh +script: make test V=1 diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..98a904c --- /dev/null +++ b/COPYING @@ -0,0 +1,16 @@ +Copyright 2017 Charles McGarvey + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES +OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1077ab5 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ + +PROVE = prove +POD2MARKDOWN = pod2markdown + +all: + +docs: README.md + +test: + $(PROVE) --ext sh $(if $(V),-v) + +README.md: docker-connect + $(POD2MARKDOWN) "$<" "$@" + +.PHONY: all docs test + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f157b75 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# NAME + +docker-connect - Easily connect to Docker sockets over SSH + +# VERSION + +Version 0.80 + +# SYNOPSIS + + docker-connect HOSTNAME [SHELL_ARGS]... + + # launch a new shell wherein docker commands go to staging-01.acme.tld + docker-connect staging-01.acme.tld + + # list the docker processes running on staging-01.acme.tld + docker-connect staging-01.acme.tld -c 'docker ps' + +# DESCRIPTION + +This script provides an alternative to Docker Machine for connecting your Docker client to a remote +Docker daemon. Instead of connecting directly to a Docker daemon listening on an external TCP port, +this script sets up a connection to the UNIX socket via SSH. + +Why? + +The main use case for this is when dealing with "permanent" app servers in an environment where you +have a team of individuals who all need access. + +Machine doesn't have a great way to support multiple concurrent users. You can add an existing +machine to which you have SSH access using the generic driver on your computer, but if your +colleague does the same then Machine will regenerate the Docker daemon TLS certificates, replacing +the ones Machine set up for you. + +Furthermore, the Docker daemon relies on TLS certificates for client authorization, which is all +fine and good, but organizations are typically not as prepared to deal with the management of client +TLS certificates as they are with the management of SSH keys. Worse, the Docker daemon doesn't +support certificate revocation lists! So if a colleague leaves, you must replace the certificate +authority and recreate and distribute certificates for each remaining member of the team. Ugh! + +Much easier to just use SSH for authorization. + +To be clear, this script isn't a full replacement for Docker Machine. For one thing, Machine has +a lot more features and can actually create machines. This script just assists with a particular +workflow that is currently underserved by Machine. + +# REQUIREMENTS + +- a Bourne-compatible, POSIX-compatible shell + + This program is written in shell script. + +- [OpenSSH](https://www.openssh.com) 6.7+ + + Needed to make the socket connection. + +- [Docker](https://www.docker.com) client + + Not technically required, but this program isn't useful without it. + +# INSTALL + +[![Build Status](https://travis-ci.org/chazmcgarvey/docker-connect.svg?branch=master)](https://travis-ci.org/chazmcgarvey/docker-connect) + +To install, just copy `docker-connect` into your `PATH` and make sure it is executable. + + # Assuming you have "$HOME/bin" in your $PATH: + cp docker-connect ~/bin/ + chmod +x ~/bin/docker-connect + +# ENVIRONMENT + +The following environment variables may affect or will be set by this program: + +- `DOCKER_CONNECT_SOCKET` + + The absolute path to the local socket. + +- `DOCKER_CONNECT_HOSTNAME` + + The hostname of the remote peer. + +- `DOCKER_CONNECT_PID` + + The PID of the SSH process maintaining the connection. + +- `DOCKER_HOST` + + The URI of the local socket. + +# TIPS + +If you run many shells and connections, having the hostname of the host that the Docker client is +connected to in your prompt may be handy. Try something like this in your local shell config file: + + if [ -n "$DOCKER_CONNECT_HOSTNAME" ] + then + PS1="[docker:$DOCKER_CONNECT_HOSTNAME] $PS1" + fi + +# AUTHOR + +Charles McGarvey + +# LICENSE + +This software is copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License diff --git a/docker-connect b/docker-connect new file mode 100755 index 0000000..ca2f3f2 --- /dev/null +++ b/docker-connect @@ -0,0 +1,308 @@ +#!/bin/sh + +: <<'=cut' +=pod + +=head1 NAME + +docker-connect - Easily connect to Docker sockets over SSH + +=head1 VERSION + +Version 0.80 + +=head1 SYNOPSIS + + docker-connect HOSTNAME [SHELL_ARGS]... + + # launch a new shell wherein docker commands go to staging-01.acme.tld + docker-connect staging-01.acme.tld + + # list the docker processes running on staging-01.acme.tld + docker-connect staging-01.acme.tld -c 'docker ps' + +=head1 DESCRIPTION + +This script provides an alternative to Docker Machine for connecting your Docker client to a remote +Docker daemon. Instead of connecting directly to a Docker daemon listening on an external TCP port, +this script sets up a connection to the UNIX socket via SSH. + +Why? + +The main use case for this is when dealing with "permanent" app servers in an environment where you +have a team of individuals who all need access. + +Machine doesn't have a great way to support multiple concurrent users. You can add an existing +machine to which you have SSH access using the generic driver on your computer, but if your +colleague does the same then Machine will regenerate the Docker daemon TLS certificates, replacing +the ones Machine set up for you. + +Furthermore, the Docker daemon relies on TLS certificates for client authorization, which is all +fine and good, but organizations are typically not as prepared to deal with the management of client +TLS certificates as they are with the management of SSH keys. Worse, the Docker daemon doesn't +support certificate revocation lists! So if a colleague leaves, you must replace the certificate +authority and recreate and distribute certificates for each remaining member of the team. Ugh! + +Much easier to just use SSH for authorization. + +To be clear, this script isn't a full replacement for Docker Machine. For one thing, Machine has +a lot more features and can actually create machines. This script just assists with a particular +workflow that is currently underserved by Machine. + +=head1 REQUIREMENTS + +=over + +=item * a Bourne-compatible, POSIX-compatible shell + +This program is written in shell script. + +=item * L 6.7+ + +Needed to make the socket connection. + +=item * L client + +Not technically required, but this program isn't useful without it. + +=back + +=head1 INSTALL + +=for markdown [![Build Status](https://travis-ci.org/chazmcgarvey/docker-connect.svg?branch=master)](https://travis-ci.org/chazmcgarvey/docker-connect) + +To install, just copy F into your C and make sure it is executable. + + # Assuming you have "$HOME/bin" in your $PATH: + cp docker-connect ~/bin/ + chmod +x ~/bin/docker-connect + +=head1 ENVIRONMENT + +The following environment variables may affect or will be set by this program: + +=over + +=item * C + +The absolute path to the local socket. + +=item * C + +The hostname of the remote peer. + +=item * C + +The PID of the SSH process maintaining the connection. + +=item * C + +The URI of the local socket. + +=back + +=head1 TIPS + +If you run many shells and connections, having the hostname of the host that the Docker client is +connected to in your prompt may be handy. Try something like this in your local shell config file: + + if [ -n "$DOCKER_CONNECT_HOSTNAME" ] + then + PS1="[docker:$DOCKER_CONNECT_HOSTNAME] $PS1" + fi + +=head1 AUTHOR + +Charles McGarvey + +=head1 LICENSE + +This software is copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut + +set -e + +prog=$(basename "$0") +version="0.80" +quiet=0 +socket="$DOCKER_CONNECT_SOCKET" +remote_socket=${REMOTE_SOCKET:-/run/docker.sock} +timeout=${TIMEOUT:-15} + +usage() { + cat <&2 "$prog: $@" + fi +} + +while getopts "hqr:s:v" opt +do + case "$opt" in + q) + quiet=$(expr $quiet + 1) + ;; + s) + socket="$OPTARG" + ;; + r) + remote_socket="$OPTARG" + ;; + h) + usage + exit 0 + ;; + v) + echo "docker-connect $version" + exit 0 + ;; + *) + usage + exit 1 + ;; + esac +done +shift $(expr $OPTIND - 1) + +connect=$1 +if [ -z "$connect" ] +then + echo >&2 "Missing HOSTNAME." + usage + exit 1 +fi +shift + +if [ -z "$socket" ] +then + socket_dir="${TMPDIR:-/tmp}/docker-connect-$(id -u)" + mkdir -p "$socket_dir" + chmod 0700 "$socket_dir" + socket="$socket_dir/docker-$$.sock" +fi + +if [ -S "$socket" ] +then + if [ -n "$DOCKER_CONNECT_HOSTNAME" ] + then + log 2 "Docker is already connected to $DOCKER_CONNECT_HOSTNAME in this shell." + exit 2 + else + log 2 "Docker socket already exists." + log 1 "To force a new connection, first remove the file: $socket" + exit 3 + fi +elif [ -e "$socket" ] +then + log 2 "Cannot create socket because another file is in the way." + log 1 "To create a new connection, you may first remove the file: $socket" + exit 4 +fi + +hostname= +port= +user= + +if echo "$connect" |grep -q ':' +then + hostname=$(echo "$connect" |cut -d: -f1) + port=$(echo "$connect" |cut -d: -f2) +else + hostname="$connect" +fi + +if echo "$hostname" |grep -q '@' +then + user=$(echo "$hostname" |cut -d@ -f1) + hostname=$(echo "$hostname" |cut -d@ -f2) +else + user=$(cat ansible.cfg 2>/dev/null |sed -ne 's/^remote_user[[:space:]]*=[[:space:]]*//p') +fi + +ssh_connect="$hostname" + +if [ "$user" != "" ] +then + ssh_connect="$user@$ssh_connect" +fi + +if [ "$port" != "" ] +then + ssh_connect="$ssh_connect -p$port" +fi + +${SSH:-ssh} $ssh_connect -L"$socket:$remote_socket" \ + -oControlPath=none -oConnectTimeout="$timeout" -nNT & +ssh_pid=$! +ssh_connected= + +handle_noconnect() { + log 2 "The connection could not be established." + log 1 "Please ensure that you can execute this command successfully:" + log 1 " ${SSH:-ssh} $ssh_connect -oControlPath=none echo OK" + exit 5 +} + +handle_disconnect() { + kill $ssh_pid 2>/dev/null || true + rm -f "$socket" + log 0 "Disconnected docker from $hostname." +} + +# Wait for the socket connection to be made. +for i in $(seq 1 "${timeout}0") +do + if [ -S "$socket" ] + then + ssh_connected=1 + break + fi + if ! kill -s 0 $ssh_pid 2>/dev/null + then + handle_noconnect + fi + sleep 0.1 +done + +if [ -z "$ssh_connected" ] +then + handle_noconnect +fi + +trap handle_disconnect EXIT + +export DOCKER_CONNECT_HOSTNAME="$hostname" +export DOCKER_CONNECT_PID="$ssh_pid" +export DOCKER_CONNECT_SOCKET="$socket" +export DOCKER_HOST="unix://$socket" + +# Remove incompatible variables set by Docker Machine. +unset DOCKER_MACHINE_NAME +unset DOCKER_CERT_PATH +unset DOCKER_TLS_VERIFY + +log 1 "Executing new shell with docker connected to $hostname." +log 0 "This connection will be terminated when the shell exits." +${SHELL:-/bin/sh} "$@" + diff --git a/t/01-basic.sh b/t/01-basic.sh new file mode 100644 index 0000000..223c244 --- /dev/null +++ b/t/01-basic.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +. ./unittest.sh +. ./tap.sh + +plan 14 + +cat <<'MOCK' >"$SSH" +#!/bin/sh +. ./tap.sh +next_test_number=1 +note 'ssh args:' "$@" +is "$1", 'foo', 'ssh: correct hostname' +is "$2", "-L$socket:/run/docker.sock", 'ssh: local port forwarding flag' +is "$3", '-oControlPath=none', 'ssh: disable control path option' +is "$4", '-oConnectTimeout=15', 'ssh: connection timeout option' +is "$5", '-nNT', 'ssh: terminal and other flags' +perl -MIO::Socket::UNIX \ + -e 'IO::Socket::UNIX->new(Type => SOCK_STREAM, Local => $ENV{socket}, Listen => 1)' +MOCK +chmod +x "$SSH" + +cat <<'MOCK' >"$SHELL" +#!/bin/sh +. ./tap.sh +next_test_number=6 +note 'shell args:' "$@" +is "$1" "bar" "shell: first shell arg is correct" +is "$2" "baz" "shell: second shell arg is correct" +is "$DOCKER_HOST" "unix://$socket" "shell: DOCKER_HOST is correct" +is "$DOCKER_CONNECT_HOSTNAME" "foo" "shell: DOCKER_CONNECT_HOSTNAME is correct" +is "$DOCKER_CONNECT_SOCKET" "$socket" "shell: DOCKER_CONNECT_SOCKET is correct" +ok '-n "$DOCKER_CONNECT_PID"' "shell: DOCKER_CONNECT_PID is set" +ok '-z "$DOCKER_MACHINE_NAME"' "shell: DOCKER_MACHINE_NAME is unset" +ok '-z "$DOCKER_CERT_PATH"' "shell: DOCKER_CERT_PATH is unset" +ok '-z "$DOCKER_TLS_VERIFY"' "shell: DOCKER_TLS_VERIFY is unset" +MOCK +chmod +x "$SHELL" + +export DOCKER_MACHINE_NAME="qux" +export DOCKER_CERT_PATH="/somewhere" +export DOCKER_TLS_VERIFY=1 + +./docker-connect -qqq foo bar baz + diff --git a/t/02-socket-errors.sh b/t/02-socket-errors.sh new file mode 100644 index 0000000..b3c2b3f --- /dev/null +++ b/t/02-socket-errors.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +. ./unittest.sh +. ./tap.sh + +plan 3 + +make_socket "$socket" + +DOCKER_CONNECT_HOSTNAME=foo ./docker-connect -qqq foo +is "$?" 2 'socket already exists and environment is configured' + +./docker-connect -qqq foo +is "$?" 3 'socket already exists' + +rm -f "$socket" + +cat <<'MOCK' >"$SSH" +#!/bin/sh +exit 1 +MOCK +chmod +x "$SSH" + +./docker-connect -qqq foo +is "$?" 5 'socket connection error' + diff --git a/t/03-options.sh b/t/03-options.sh new file mode 100644 index 0000000..ac10692 --- /dev/null +++ b/t/03-options.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +. ./unittest.sh +. ./tap.sh + +plan 3 + +./docker-connect -h |grep 'OPTIONS' >/dev/null 2>&1 +is "$?" 0 'the -h flag works' + +./docker-connect -v |grep '^docker-connect [[:digit:]]' >/dev/null 2>&1 +is "$?" 0 'the -v flag works' + +./docker-connect -Z foo 2>/dev/null +is "$?" 1 'invalid option correctly fails' + diff --git a/tap.sh b/tap.sh new file mode 100644 index 0000000..8e0ed88 --- /dev/null +++ b/tap.sh @@ -0,0 +1,114 @@ + +: <<'=cut' +=pod + +=head1 NAME + +tap.sh - Useful subset of TAP (Test Anything Protocol) for shell scripts + +=head1 SYNOPSIS + + . ./tap.sh + + plan 6 + + ok '1 = 1' 'Make sure that one equals one' + ok '1 != 2' 'Make sure that one is not two' + + is 2 2 'Two is two' + isnt 2 3 'Two is not three' + + pass 'It worked!' + fail 'Uh oh' + + diag 'This is a diagnostic message' + note - < + +=head1 LICENSE + +This software is copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut + +next_test_number=1 + +plan() { + _n=$1; shift + echo "1..$_n" +} + +ok() { + _t=$1; shift + _m=$1; shift + if eval "test $_t"; then pass "$_m"; else fail "$_m"; fi +} + +is() { + _a=$1; shift + _b=$1; shift + _m=$1; shift + if [ "$_a" = "$_b" ] + then + pass "$_m" + else + fail "$_m" + note "Expected: $_b" " Got: $_a" + fi +} + +isnt() { + _a=$1; shift + _b=$1; shift + _m=$1; shift + if [ "$_a" != "$_b" ] + then + pass "$_m" + else + fail "$_m" + note "Expected: != $_b" " Got: $_a" + fi +} + +pass() { + echo "ok $next_test_number - $@" + next_test_number=$(expr $next_test_number + 1) +} + +fail() { + echo "not ok $next_test_number - $@" + next_test_number=$(expr $next_test_number + 1) +} + +diag() { + if [ "$1" != '-' ] + then + for _m in "$@" + do + echo "# $_m" + done + else + while read _m + do + echo "# $_m" + done + fi +} + +note() { + diag "$@" +} + diff --git a/unittest.sh b/unittest.sh new file mode 100644 index 0000000..da34030 --- /dev/null +++ b/unittest.sh @@ -0,0 +1,32 @@ + +temp=$(mktemp -d 2>/dev/null || mktemp -d -t 'test') + +export DOCKER_CONNECT_UNIT_TEST=1 +export SHELL="$temp/mockshell" +export SSH="$temp/mockssh" +export socket="$temp/test.sock" +export DOCKER_CONNECT_SOCKET="$socket" + +cleanup() { + rm -rf "$temp" +} + +trap cleanup EXIT + +cat <<'MOCK' >"$SSH" +#!/bin/sh +perl -MIO::Socket::UNIX \ + -e 'IO::Socket::UNIX->new(Type => SOCK_STREAM, Local => $ENV{socket}, Listen => 1)' +MOCK +chmod +x "$SSH" + +cat <<'MOCK' >"$SHELL" +#!/bin/sh +MOCK +chmod +x "$SHELL" + +make_socket() { + SOCKET=$1 perl -MIO::Socket::UNIX \ + -e 'IO::Socket::UNIX->new(Type => SOCK_STREAM, Local => $ENV{SOCKET}, Listen => 1)' +} + -- 2.45.2