#!/bin/bash # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # # Copyright 2016 Joyent, Inc. # # # Setup your environment for `docker` to use with SmartDataCenter. # # The basic steps are: # # 1. Select the data center (i.e. the CloudAPI URL). # 2. Select the account (login) to use. # 3. Ensure the account has an SSH key to use. # 4. Generate a client certificate from your SSH key and save that where # `docker` can use it: "~/.sdc/docker/$account/". # if [[ -n "$TRACE" ]]; then export PS4='[\D{%FT%TZ}] ${BASH_SOURCE}:${LINENO}: ${FUNCNAME[0]:+${FUNCNAME[0]}(): }' set -o xtrace fi set -o errexit set -o pipefail # ---- globals NAME=$(basename $0) VERSION=1.0.0 # Ensure locale-independent output for 'date' for signing headers. # (See DOCKER-799.) LC_ALL=C CERT_BASE_DIR=$HOME/.sdc/docker CURL_OPTS=" -H user-agent:sdc-docker-setup/$VERSION" PROFILE_NAME_RE='^[a-z][a-z0-9\._-]+$' COAL=root@10.99.99.7 # ---- support functions function fatal { echo "" >&2 echo "* * *" >&2 printf "$NAME: fatal error: $*\n" >&2 exit 1 } function warn { echo "$NAME: warn: $*" >&2 } function info { if [[ $optQuiet == "true" ]]; then return fi echo "$*" } function envInfo { if [[ -n "$envFile" ]]; then echo "$*" >>$envFile fi echo " $*" } function debug { #echo "$NAME: debug: $@" >&2 true } function usage { echo "Usage:" echo " sdc-docker-setup [SDC-CLOUDAPI-OR-REGION] [ACCOUNT] [SSH-PRIVATE-KEY-PATH]" echo "" echo "Options:" echo " -h Print this help and exit." echo " -V Print version and exit." echo " -q Quiet output. Only print out environment setup commands." echo " -c Force CoaL ssh user@host. (Defaults to $COAL)" echo " -f Force set up without checks (check that the given login and" echo " ssh key exist in the SDC CloudAPI, check that the Docker" echo " hostname responds, etc)." echo " -k Disable SSH certificate verification (e.g. if using CoaL" echo " for development)." echo " -s Include SDC_* environment variables for setting up SDC CLI." echo " Otherwise, only the 'docker' env vars are emitted." echo " -p PROFILE" echo " The profile name for this Docker host and account." echo " Profile info is stored under '~/.sdc/docker/\$profile/'." echo " It defaults to the ACCOUNT, it must match '${PROFILE_NAME_RE}'." # TODO: examples } function dockerInfo { local dockerUrl response dockerUrl=$1 local curlOpts if [[ $optInsecure == "true" ]]; then curlOpts=" -k" fi curl $CURL_OPTS -sSf $curlOpts --connect-timeout 10 \ --url $dockerUrl/v1.16/info } # Return ssh fingerprint in the form "he:xh:ex:he:xh:..." # OpenSSH_6.8 changes the "-l" flag output. function sshGetMD5Fingerprint() { local sshPubKeyPath=$1 local s s=$(ssh-keygen -E md5 -l -f "$sshPubKeyPath" 2> /dev/null) if [[ $? -eq 0 ]]; then echo "$s" | awk '{print $2}' | tr -d '\n' | cut -d: -f2-; else # OpenSSH version < 6.8 ssh-keygen -l -f "$sshPubKeyPath" | awk '{print $2}' | tr -d '\n'; fi } function cloudapiVerifyAccount() { local cloudapiUrl account sshPrivKeyPath sshKeyId now signature response cloudapiUrl=$1 account=$2 sshPrivKeyPath=$3 sshKeyId=$4 now=$(date -u "+%a, %d %h %Y %H:%M:%S GMT") signature=$(echo ${now} | tr -d '\n' | openssl dgst -sha256 -sign $sshPrivKeyPath | openssl enc -e -a | tr -d '\n') local curlOpts if [[ $coal == "true" || $optInsecure == "true" ]]; then curlOpts=" -k" fi local response status response=$(curl $CURL_OPTS $curlOpts -isS \ -H "Accept:application/json" -H "api-version:*" -H "Date: ${now}" \ -H "Authorization: Signature keyId=\"/$account/keys/$sshKeyId\",algorithm=\"rsa-sha256\" ${signature}" \ --url $cloudapiUrl/--ping) status=$(echo "$response" | head -1 | awk '{print $2}') case "$status" in 401) if [[ -n "$portalUrl" ]]; then fatal "invalid credentials" \ "\nVisit <$portalUrl> to create the '$account' account" \ "\nand/or add your SSH public key ($sshPubKeyPath)" elif [[ "$coal" == "true" ]]; then fatal "invalid credentials" \ "\n You must create the '$account' account and/or add your SSH" \ "\n public key ($sshPubKeyPath) to the" \ "\n given SmartDataCenter."\ "\n" \ "\n On CoaL you can do this via:" \ "\n scp $sshPubKeyPath $COAL:/var/tmp/id_rsa.pub" \ "\n ssh $COAL" \ "\n sdc-useradm get $account >/dev/null 2>/dev/null || \\" \ "\n echo '{\"login\":\"$account\",\"userpassword\":\"secret123\",\"cn\":\"$account Test User\",\"email\":\"$account@example.com\"}' | sdc-useradm create -A" \ "\n sdc-useradm add-key $account /var/tmp/id_rsa.pub" else fatal "invalid credentials" \ "\n You must create the '$account' account and/or add your SSH" \ "\n public key ($sshPubKeyPath) to the" \ "\n given SmartDataCenter." fi ;; 200) info "CloudAPI access verified." info '' ;; *) if [[ "$status" == "400" && "$coal" == "true" ]]; then fatal "'Bad Request' from CloudAPI. Possibly clock skew. Otherwise, check the CloudAPI log.\n\n$response" fi fatal "unexpected CloudAPI response:\n\n$response" ;; esac } function cloudapiGetDockerService() { local cloudapiUrl account sshPrivKeyPath sshKeyId now signature response cloudapiUrl=$1 account=$2 sshPrivKeyPath=$3 sshKeyId=$4 # TODO: share the 'cloudapi request' code now=$(date -u "+%a, %d %h %Y %H:%M:%S GMT") signature=$(echo ${now} | tr -d '\n' | openssl dgst -sha256 -sign $sshPrivKeyPath | openssl enc -e -a | tr -d '\n') local curlOpts if [[ $coal == "true" || $optInsecure == "true" ]]; then curlOpts=" -k" fi # TODO: a test on ListServices being a single line of JSON local response status dockerService response=$(curl $CURL_OPTS $curlOpts -isS \ -H "Accept:application/json" -H "api-version:*" -H "Date: ${now}" \ -H "Authorization: Signature keyId=\"/$account/keys/$sshKeyId\",algorithm=\"rsa-sha256\" ${signature}" \ --url $cloudapiUrl/$account/services) status=$(echo "$response" | head -1 | awk '{print $2}') if [[ "$status" == "403" ]]; then # Forbidden (presumably from an invite-only DC). # Assuming the error response is all on the last line: # {"code":"NotAuthorized","message":"Forbidden (This serv ..."} local errmsg errmsg=$(echo "$response" | tail -1 | sed -E 's/.*"message":"([^"]*)".*/\1/') fatal "cannot setup for this datacenter: $errmsg" elif [[ "$status" != "200" ]]; then warn "could not get Docker service endpoint from CloudAPI (status=$status)" return fi if [[ -z "$(echo "$response" | (grep '"docker"' || true))" ]]; then warn "could not get Docker service endpoint from CloudAPI (no docker service listed)" return fi dockerService=$(echo "$response" | tail -1 | sed -E 's/.*"docker":"([^"]*)".*/\1/') if [[ "$dockerService" != "$response" ]]; then echo $dockerService fi } function downloadCaCertificate() { local dockerHttpsUrl="https://$1" local outFile=$2 local curlOpts="" if [[ "$coal" == "true" || $optInsecure == "true" ]]; then curlOpts="-k" fi curl $CURL_OPTS $curlOpts --connect-timeout 10 \ --url "$dockerHttpsUrl/ca.pem" -o $outFile 2>/dev/null } # Arguments: # $1 - the function that will handle the printing # $2 - the indentation string function sdcEnvConfiguration() { local indent=$2 if [[ -n "$optSdcSetup" ]]; then $1 "${indent}export SDC_URL=$cloudapiUrl" $1 "${indent}export SDC_ACCOUNT=$account" if [[ -f $sshPubKeyPath ]]; then $1 "${indent}export SDC_KEY_ID=$(sshGetMD5Fingerprint $sshPubKeyPath)" else $1 "${indent}# Could not calculate KEY_ID: SSH public key '$sshPubKeyPath' does not exist" $1 "${indent}export SDC_KEY_ID=''" fi if [[ "$coal" == "true" || $optInsecure == "true" ]]; then $1 "${indent}export SDC_TESTING=1" fi fi } # ---- mainline # This script currently requires Bash-isms, so guard for that. if [ "$POSIXLY_CORRECT" = "y" ]; then fatal "This script requires Bash running in *non*-posix mode. Please re-run with 'bash sdc-docker-setup.sh ...'." fi optQuiet= optForce= optInsecure= optSdcSetup= optProfileName= while getopts "c:hVqfksp:" opt; do case "$opt" in h) usage exit 0 ;; V) echo "$(basename $0) $VERSION" exit 0 ;; c) optCoalHost=$OPTARG ;; q) optQuiet=true ;; f) optForce=true ;; k) optInsecure=true ;; s) optSdcSetup=true ;; p) if [[ -z $(echo "$OPTARG" | (egrep "$PROFILE_NAME_RE" || true)) ]]; then fatal "profile name, '$OPTARG', does not match '$PROFILE_NAME_RE'" fi optProfileName=$OPTARG ;; *) usage exit 1 ;; esac done shift $((OPTIND - 1)) if [[ -n $optCoalHost ]]; then COAL=$optCoalHost fi # Ping the URL passed as the first positional parameter and outputs one # of three possible strings: # - "available" if the ping endpoint responded with an OK status to the ping # request # - "maintenance" if the ping endpoint responded with a 503 status code to # the ping request. # - "unavailable" if the ping endpoint responded with any other response function pingCloudAPIUrl() { local cloudApiUrl=$1 local response status local curlOpts if [[ $coal == "true" || $optInsecure == "true" ]]; then curlOpts=" -k" fi response=$(curl -sSi $curlOpts $cloudApiUrl/--ping) status=$(echo "$response" | head -1 | awk '{print $2}') case "$status" in 200) echo "available" ;; 503) echo "maintenance" ;; *) echo "unavailable" ;; esac } # Get the cloudapi URL. Default to the cloudapi for the current pre-release # docker service. Eventually can default to the user's SDC_URL setting. # # Offer some shortcuts: # - coal: Find the cloudapi in your local CoaL via ssh. # - : Treat as a Joyent Cloud region name and use: # https://$dc.api.joyent.com # - if given without 'https://' prefix: add that automatically promptedUser= # While the URL passed on the command line is not a valid cloudapi URL, # prompt for a new one. cloudapiUrl=$1 while true; do if [[ -z "$cloudapiUrl" ]]; then defaultCloudapiUrl=https://us-east-1.api.joyent.com #info "Enter the SDC Docker hostname. Press enter for the default." printf "SDC CloudAPI URL [$defaultCloudapiUrl]: " read cloudapiUrl promptedUser=true fi if [[ -z "$cloudapiUrl" ]]; then portalUrl=https://my.joyent.com cloudapiUrl=$defaultCloudapiUrl elif [[ "$cloudapiUrl" == "coal" ]]; then coal=true cloudapiUrl=https://$(ssh -o ConnectTimeout=5 $COAL "vmadm lookup -j alias=cloudapi0 | json -ae 'ext = this.nics.filter(function (nic) { return nic.nic_tag === \"external\"; })[0]; this.ip = ext ? ext.ip : this.nics[0].ip;' ip") if [[ -z "$cloudapiUrl" ]]; then fatal "could not find the cloudapi0 zone IP in CoaL" fi elif [[ "${cloudapiUrl/./X}" == "$cloudapiUrl" ]]; then portalUrl=https://my.joyent.com cloudapiUrl=https://$cloudapiUrl.api.joyent.com elif [[ "${cloudapiUrl:0:8}" != "https://" ]]; then cloudapiUrl=https://$cloudapiUrl fi debug "cloudapiUrl: $cloudapiUrl" cloudApiUrlStatus=$(pingCloudAPIUrl "$cloudapiUrl") if [[ "$cloudApiUrlStatus" == "maintenance" ]]; then if [[ $promptedUser == "true" ]]; then printf "\"$cloudapiUrl\" is currently in maintenance, please try another SDC CloudAPI URL\n" else fatal "\"$cloudapiUrl\" is currently in maintenance" fi elif [[ "$cloudApiUrlStatus" == "unavailable" ]]; then if [[ $promptedUser == "true" ]]; then printf "Cannot ping \"$cloudapiUrl\", are you sure it is a valid SDC CloudAPI URL?\n" else fatal "Cannot ping \"$cloudapiUrl\", are you sure it is a valid SDC CloudAPI URL?" fi elif [[ "$cloudApiUrlStatus" == "available" ]]; then break else fatal "There was an error when checking $cloudapiUrl's status\n" fi # Even if cloudapiUrl was set on the command line, reset it so that # we prompt for a new one because the one passed on the command line # was invalid. cloudapiUrl= done # Get the account to use. account=$2 if [[ -z "$account" ]]; then defaultAccount=$SDC_ACCOUNT if [[ -z "$defaultAccount" ]]; then printf "SDC account: " else printf "SDC account [$defaultAccount]: " fi read account promptedUser=true fi if [[ -z "$account" && -n "$defaultAccount" ]]; then account=$defaultAccount fi debug "account: $account" if [[ -z "$account" ]]; then fatal "no account (login name) was given" fi # Get SSH priv key path. sshPrivKeyPath=$3 if [[ -z "$sshPrivKeyPath" ]]; then # TODO: Using SDC_KEY_ID and search ~/.ssh for a matching key. if [[ -f $HOME/.ssh/id_rsa ]]; then defaultSSHPrivKeyPath=$HOME/.ssh/id_rsa fi if [[ -z "$defaultSSHPrivKeyPath" ]]; then printf "SSH private key path: " else printf "SSH private key [$defaultSSHPrivKeyPath]: " fi read sshPrivKeyPath promptedUser=true fi if [[ -z "$sshPrivKeyPath" && -n "$defaultSSHPrivKeyPath" ]]; then sshPrivKeyPath=$defaultSSHPrivKeyPath fi sshPrivKeyPath=$(bash -c "echo $sshPrivKeyPath") # resolve '~' if [[ ! -f $sshPrivKeyPath ]]; then fatal "'$sshPrivKeyPath' does not exist" fi debug "sshPrivKeyPath: $sshPrivKeyPath" if [[ -z "$sshPrivKeyPath" ]]; then fatal "no SSH private key path was given" fi [[ $promptedUser == "true" ]] && info "" info "Setting up Docker client for SDC using:" info " CloudAPI: $cloudapiUrl" info " Account: $account" info " Key: $sshPrivKeyPath" info "" sshPubKeyPath=$sshPrivKeyPath.pub if [[ $optForce != "true" ]]; then if [[ ! -f $sshPubKeyPath ]]; then fatal "could not verify account/key: SSH public key does not exist at '$sshPubKeyPath'" fi sshKeyId=$(sshGetMD5Fingerprint $sshPubKeyPath) debug "sshKeyId: $sshKeyId" info "If you have a pass phrase on your key, the openssl command will" info "prompt you for your pass phrase now and again later." info '' info "Verifying CloudAPI access." cloudapiVerifyAccount "$cloudapiUrl" "$account" "$sshPrivKeyPath" "$sshKeyId" fi info "Generating client certificate from SSH private key." profileName=$optProfileName if [[ -z "$profileName" ]]; then profileName=$account fi certDir="$CERT_BASE_DIR/$profileName" keyPath=$certDir/key.pem certPath=$certDir/cert.pem csrPath=$certDir/csr.pem caPath=$certDir/ca.pem mkdir -p $(dirname $keyPath) openssl rsa -in $sshPrivKeyPath -outform pem >$keyPath 2>/dev/null certSubject= if [[ -n "$(which uname 2>/dev/null)" && "$(uname)" == MINGW* ]]; then # On the mingw32 shell on Windows we need to work around mingw32 converting # args that look like Windows paths. # See for details. certSubject="//CN=$account" else certSubject="/CN=$account" fi openssl req -new -key $keyPath -out $csrPath -subj "$certSubject" >/dev/null 2>/dev/null # TODO: expiry? openssl x509 -req -days 365 -in $csrPath -signkey $keyPath -out $certPath >/dev/null 2>/dev/null rm $csrPath # The signing request has been used - remove it. info "Wrote certificate files to $certDir" info '' if [[ $optForce != "true" ]]; then info "Get Docker host endpoint from cloudapi." dockerService=$(cloudapiGetDockerService "$cloudapiUrl" "$account" "$sshPrivKeyPath" "$sshKeyId") dockerHostAndPort=${dockerService#*://} # remove 'tcp://' from start dockerHost=${dockerHostAndPort%:*} # remove ':2376' from end dockerPort=${dockerHostAndPort#*:} # remove everything before ':2376' if [[ -n "$dockerService" ]]; then info "Docker service endpoint is: $dockerService" else info "Could not discover service endpoint for DOCKER_HOST from CloudAPI." fi fi # Add the sdc-docker ca.pem (server certificate verification). downloadCaCertificate $dockerHostAndPort $caPath # TODO: success even if can't discover service endpoint for docker? info "" info "* * *" info "Success. Set your environment as follows: " info "" envFile=$certDir/env.sh rm -f $envFile touch $envFile sdcEnvConfiguration envInfo "" envInfo "export DOCKER_CERT_PATH=$certDir" if [[ -n "$dockerService" ]]; then envInfo "export DOCKER_HOST=$dockerService" envInfo "export DOCKER_CLIENT_TIMEOUT=300" envInfo "export COMPOSE_HTTP_TIMEOUT=300" if [[ $dockerHost =~ ^[0-9]+ ]]; then # IP address - let them know a FQDN is needed to use DOCKER_TLS_VERIFY. envInfo "unset DOCKER_TLS_VERIFY" envInfo "alias docker=\"docker --tls\"" dockerHostname="my.triton" info "" info "Your Docker host is not a DNS name, but an IP. If you want to run docker" info "with TLS verification, you can configure to use a '${dockerHostname}' DNS" info "name as follows (use this instead of the setup block above):" info "" info " sudo sed -e '\$G; \$s/\$/${dockerHost} ${dockerHostname}/;' -i.bak /etc/hosts" sdcEnvConfiguration info " " info " export DOCKER_CERT_PATH=$certDir" info " export DOCKER_HOST=tcp://${dockerHostname}:${dockerPort}" info " export DOCKER_TLS_VERIFY=1" info " export DOCKER_CLIENT_TIMEOUT=300" info " export COMPOSE_HTTP_TIMEOUT=300" else # Fully qualified domain name... assume the cert is already setup. envInfo "export DOCKER_TLS_VERIFY=1" fi else envInfo "# See the product docs for the value to use for DOCKER_HOST." envInfo "export DOCKER_HOST='tcp://:2376'" envInfo "export DOCKER_CLIENT_TIMEOUT=300" fi info "" info "Then you should be able to run 'docker info' and see your account" info "name 'SDCAccount: ${account}' in the output." info "" info "Note: If you receive any docker compose warning about the" info "DOCKER_CLIENT_TIMEOUT environment variable being deprecated," info "simply unset it and remove it from env.sh."