From 0fd334f30d660d850b42fa618caf87d0eade5e92 Mon Sep 17 00:00:00 2001 From: Tomas Celaya Date: Mon, 8 Jan 2018 12:34:41 -0800 Subject: [PATCH] Squashed enhancement/49-tls-encryption --- .dockerignore | 1 + .gitignore | 1 + Dockerfile | 4 +- README.md | 48 +++- bin/consul-manage | 159 ++++++++++++- ca/Dockerfile | 88 +++++++ etc/consul.hcl | 6 + etc/containerpilot.json5 | 2 +- examples/compose/docker-compose.yml | 7 + examples/triton-multi-dc/setup-multi-dc.sh | 41 +++- examples/triton/setup.sh | 46 +++- setup-encryption.sh | 253 +++++++++++++++++++++ 12 files changed, 642 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 ca/Dockerfile create mode 100755 setup-encryption.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb29379 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +secrets/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7750de..3bc14e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ _env* examples/triton-multi-dc/docker-compose-*.yml +secrets/* diff --git a/Dockerfile b/Dockerfile index 1e01a21..654bf14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,9 +19,9 @@ RUN export CONSUL_CHECKSUM=585782e1fb25a2096e1776e2da206866b1d9e1f10b71317e682e0 && rm /tmp/${archive} # Add Containerpilot and set its configuration -ENV CONTAINERPILOT_VER=3.6.0 +ENV CONTAINERPILOT_VER=3.6.1 ENV CONTAINERPILOT=/etc/containerpilot.json5 -RUN export CONTAINERPILOT_CHECKSUM=1248784ff475e6fda69ebf7a2136adbfb902f74b \ +RUN export CONTAINERPILOT_CHECKSUM=57857530356708e9e8672d133b3126511fb785ab \ && curl -Lso /tmp/containerpilot.tar.gz \ "https://github.com/joyent/containerpilot/releases/download/${CONTAINERPILOT_VER}/containerpilot-${CONTAINERPILOT_VER}.tar.gz" \ && echo "${CONTAINERPILOT_CHECKSUM} /tmp/containerpilot.tar.gz" | sha1sum -c \ diff --git a/README.md b/README.md index 0f0b5c7..33b79bf 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,10 @@ Note: the `cns.joyent.com` hostnames cannot be resolved from outside the datacen - `8300`: Server RPC port (TCP) - `8302`: Serf WAN gossip port (TCP + UDP) +- `CONSUL_TLS_PATH`: Specifies the location of a directory which will contain the TLS key file, certificate, and root certificate. See the section on [securing Consul](#consul-encryption) for more details. + +- `CONSUL_ENCRYPT`: Secret key used for encrypting gossip. Consul flag: [`-encrypt`](https://www.consul.io/docs/agent/options.html#_encrypt). See the section on [securing Consul](#consul-encryption) for more details. + ## Using this in your own composition There are two ways to run Consul and both come into play when deploying ContainerPilot, a cluster of Consul servers and individual Consul client agents. @@ -184,7 +188,49 @@ Some details about how Docker containers work on Triton have specific bearing on Consul supports TLS encryption for RPC and symmetric pre-shared key encryption for its gossip protocol. Deploying these features requires managing these secrets, and a demonstration of how to do so can be found in the [Vault example](https://github.com/autopilotpattern/vault). -### Testing +### Configuration + +The `CONSUL_TLS_PATH` environment variable will be checked on startup and is used to indicate that TLS should be configured. If it is defined the container will await the creation of the directory specified in `CONSUL_TLS_PATH` and expect the directory to contain a CA certificate along with a datacenter-specific certificate and key. These files will be used to configure `ca_cert`, `cert_file` and `key_file` respectively in Consul, in addition to enabling both `verify_outgoing` and `verify_incoming`. The secret key used for gossip traffic can be provided directly as the environment variable `CONSUL_ENCRYPT`. + +The `./setup.sh` and `./setup-multi-dc.sh` scripts both accept `-t/--tls-path` and `-g/--gossip-path` parameters to set `CONSUL_TLS_PATH` and `CONSUL_ENCRYPT` environment variables respectively. Note that `--tls-path` only specifies _where_ the key material will be injected. Deployed containers will remain idle until certificates have been installed by `./setup-encryption.sh upload` + +### Generating certificates + +In order to simplify certificate generation a `Dockerfile` can be found within the `ca` directory which creates a Certificate Authority on build. Use `./setup-encryption.sh build -i ` to build the container. This same image name can then be used with `./setup-encryption.sh generate -i -d -g ` to generate certificates. + +### Installing certificates + +When `CONSUL_TLS_PATH` is specified, the `preStart` ContainerPilot job awaits the creation of the relevant certificates and key (i.e. `CONSUL_CACERT`, `CONSUL_CLIENT_CERT`, `CONSUL_CLIENT_KEY`) and uses the [ContainerPilot Control plane](https://www.joyent.com/containerpilot/docs/configuration/control-plane) from within the job to `-putenv` and `-reload` ContainerPilot itself. Without the `-putenv` calls to set the certificates and key, the `preStart` job would see `CONSUL_TLS_PATH` and attempt to restart ContainerPilot indefinitely. + +Certificates and the private key can be installed in running containers with `./setup-encryption.sh upload -d -t `. Note that `-d` is only used to select the directory containing the key material, you must still run `eval "$(triton env -d)"` with the relevant datacenter's profile in order to target the correct docker endpoint. + +The `upload` command will assume the default `docker-compose` file (`./docker-compose.yml`) and project name (the current working directory), reading `COMPOSE_FILE` and `COMPOSE_PROJECT_NAME` if they are defined, but can be overriden with `-f` and `-p` in the same way as `docker-compose` itself. + +### Encrypting gossip + +The `CONSUL_ENCRYPT` parameter can be passed to encrypt gossip traffic. Use `./setup-encryption.sh generate -g ` to generate a file in the `secrets` directory with the provided name. For local deployments, simply copy the contents of the generated file as an environment variable in `examples/compose/docker-compose.yml`. For Triton deployments, the setup scripts accept a `-g` parameter to specify a relative path to a gossip file (e.g. `examples/triton/setup.sh -g ../../secrets/gossip`) and will inject the contents of the gossip key file as `CONSUL_ENCRYPT` in the relevant `_env` file. + +### How do I know if it's working? + +Assuming you've spun up `examples/compose/docker-compose.yml` after generating a certificate for the "dc1" datacenter (which would imply the `secrets/dc1` directory was generated) then you'll notice commands fail with odd HTTP responses unless the correct certificates and key are supplied: + +``` +$ docker-compose exec consul consul info -client-cert=/ssl/dc1.crt -client-key=/ssl/dc1.key -ca-file=/ssl/ca.crt +Error querying agent: Get http://127.0.0.1:8500/v1/agent/self: net/http: HTTP/1.x transport connection broken: malformed HTTP response "\x15\x03\x01\x00\x02\x02" + +$ docker-compose exec consul consul info -client-cert=/ssl/dc1.crt -client-key=/ssl/dc1.key -http-addr=https://consul:8500 +Error querying agent: Get https://consul:8500/v1/agent/self: x509: certificate signed by unknown authority + +$ docker-compose exec consul consul info -client-cert=/ssl/dc1.crt -ca-file=/ssl/ca.crt -http-addr=https://consul:8500 +Error querying agent: Get https://consul:8500/v1/agent/self: remote error: tls: bad certificate + +# with everything in place +$ docker-compose exec consul consul members -client-cert=/ssl/dc1.crt -client-key=/ssl/dc1.key -ca-file=/ssl/ca.crt -http-addr=https://consul:8500 +Node Address Status Type Build Protocol DC Segment +01e297f34346 172.23.0.2:8301 alive server 1.0.0 2 dc1 +``` + +## Testing The `tests/` directory includes integration tests for both the Triton and Compose example stacks described above. Build the test runner by making sure you've pulled down the submodule with `git submodule update --init` and then `make build/tester`. diff --git a/bin/consul-manage b/bin/consul-manage index 320f83d..eb2c037 100755 --- a/bin/consul-manage +++ b/bin/consul-manage @@ -12,10 +12,14 @@ preStart() { sed -i "s/CONSUL_DATACENTER_NAME/${CONSUL_DATACENTER_NAME}/" /etc/consul/consul.hcl elif [ -f "/native/usr/sbin/mdata-get" ]; then DETECTED_DATACENTER_NAME=$(/native/usr/sbin/mdata-get sdc:datacenter_name) + # re-export so it can be used later in this script + export CONSUL_DATACENTER_NAME=$DETECTED_DATACENTER_NAME _log "Updating consul datacenter name (detected: '${DETECTED_DATACENTER_NAME}')" sed -i "s/CONSUL_DATACENTER_NAME/${DETECTED_DATACENTER_NAME}/" /etc/consul/consul.hcl else _log "Updating consul datacenter name (default: 'dc1')" + # re-export so it can be used later in this script + export CONSUL_DATACENTER_NAME=dc1 sed -i "s/CONSUL_DATACENTER_NAME/dc1/" /etc/consul/consul.hcl fi @@ -58,6 +62,124 @@ preStart() { # advertise_addr_wan tells nodes their public address for WAN communication updateConfigFromEnvOrDefault 'advertise_addr_wan' 'CONSUL_ADVERTISE_ADDR_WAN' "$IP_ADDRESS" + + # there's no consul env for this + if [ -n "$CONSUL_ENCRYPT" ]; then + sed -i '/^encrypt =/d' /etc/consul/consul.hcl + _log "Updating consul translate_wan_addrs field" + echo "encrypt = \"$CONSUL_ENCRYPT\"" >> /etc/consul/consul.hcl + else + _log "Skipping gossip encryption configuration" + fi + + # this block looks for the files used for enabling TLS support in CONSUL_TLS_PATH + # and populates the configs in Consul, but not containerpilot + if [ -n "$CONSUL_TLS_PATH" ] && [ -z "$CONSUL_CACERT$CONSUL_CLIENT_CERT$CONSUL_CLIENT_KEY"]; then + + # notice we are intentionally not exporting these as envs + # nor are we using containerpilot -putenv + local consul_cacert="$CONSUL_TLS_PATH/ca.crt" + local consul_client_cert="$CONSUL_TLS_PATH/$CONSUL_DATACENTER_NAME.crt" + local consul_client_key="$CONSUL_TLS_PATH/$CONSUL_DATACENTER_NAME.key" + + until find "$consul_cacert" "$consul_client_cert" "$consul_client_key" &>/dev/null + do + sleep 5 + _log "Still waiting for TLS key material at: CONSUL_CACERT=$consul_cacert CONSUL_CLIENT_CERT=$consul_client_cert CONSUL_CLIENT_KEY=$consul_client_key" + done + + if [ -f "$consul_cacert" ] \ + && [ -f "$consul_client_cert" ] \ + && [ -f "$consul_client_key" ]; then + + echo "TLS files found. Updating consul configs: ca_file, cert_file, key_file, verify_outgoing, verify_incoming" + + # these need to be set in the config since containerpilot looks at the same + # environment variables. containerpilot will crash if it's booting and these are missing + + sed -i '/^ca_file/d' /etc/consul/consul.hcl + echo "ca_file = \"$(realpath $consul_cacert)\"" >> /etc/consul/consul.hcl + # /usr/local/bin/containerpilot -putenv "CONSUL_CACERT=$CONSUL_CACERT" + + sed -i '/^cert_file/d' /etc/consul/consul.hcl + echo "cert_file = \"$(realpath $consul_client_cert)\"" >> /etc/consul/consul.hcl + # /usr/local/bin/containerpilot -putenv "CONSUL_CLIENT_CERT=$CONSUL_CLIENT_CERT" + + sed -i '/^key_file/d' /etc/consul/consul.hcl + echo "key_file = \"$(realpath $consul_client_key)\"" >> /etc/consul/consul.hcl + # /usr/local/bin/containerpilot -putenv "CONSUL_CLIENT_KEY=$CONSUL_CLIENT_KEY" + + # /usr/local/bin/containerpilot -putenv "CONSUL_HTTP_SSL=true" + + # maybe just listen unencrypted on a private address? + + sed -i '/^verify_outgoing =/d' /etc/consul/consul.hcl + echo "verify_outgoing = true" >> /etc/consul/consul.hcl + + sed -i '/^verify_incoming =/d' /etc/consul/consul.hcl + echo "verify_incoming = true" >> /etc/consul/consul.hcl + + sed -i '/^verify_incoming_rpc =/d' /etc/consul/consul.hcl + echo "verify_incoming_rpc = true" >> /etc/consul/consul.hcl + + sed -i '/^verify_incoming_https =/d' /etc/consul/consul.hcl + echo "verify_incoming_https = true" >> /etc/consul/consul.hcl + + # docs just before https://www.consul.io/docs/agent/options.html#configuration-key-reference say: + + # Consul will not enable TLS for the HTTP API unless the + # https port has been assigned a port number > 0. + + sed -i 's/^ HTTP_PORT/ http = 8500/' /etc/consul/consul.hcl + sed -i 's/^ HTTPS_PORT/ https = 8501/' /etc/consul/consul.hcl + + # if TLS is being configured, we probably want to lock down HTTP to only localhost, or + # a private address. By default, (the empty string) we will listen on a private address, + # unless the user has requested otherwise (either with something falsy, or with "localhost" + # + # Leaving this unspecified and attempting to visit the web UI at the 8500 address (when + # encryption is set up correctly) will lead to `ERR_EMPTY_RESPONSE` or `curl: (52) Empty reply from server` + case "$CONSUL_TLS_PRIVATE_HTTP" in + 0 | f | n | false | no) + sed -i 's/^ HTTP_ADDR/ http = "{{ GetPublicIP }}"/' /etc/consul/consul.hcl ;; + 1 | t | y | true | yes | '') + sed -i 's/^ HTTP_ADDR/ http = "{{ GetPrivateIP }}"/' /etc/consul/consul.hcl ;; + localhost) + sed -i 's/^ HTTP_ADDR/ http = "127.0.0.1"/' /etc/consul/consul.hcl ;; + esac + + sed -i 's/^ HTTPS_ADDR/ https = "{{ GetPublicIP }}"/' /etc/consul/consul.hcl + + ## we should'nt need to do this if we're not giving the certs to ContainerPilot + # echo "Attempting to reload" + + # /usr/local/bin/containerpilot -reload + else + # TODO: not sure what do to here + echo "TLS files missing from TLS directory. Exiting!" + exit 1 + fi + else + _log "Skipping RPC server TLS configuration" + + # remove HTTPS placeholder line and set http address to 8500 + sed -i '/^ HTTPS_PORT/d' /etc/consul/consul.hcl + sed -i 's/^ HTTP_PORT/ http = 8500/' /etc/consul/consul.hcl + fi +} + +preStop() { + echo " ~~~ preStop ~~~" + + if consul info &>/dev/null; then + consul leave + else + echo "We're still bootstrapping, probably." + fi +} + +postStop() { + echo " ~~~ postStop ~~~" } # @@ -72,9 +194,41 @@ preStart() { # we've got the whole cluster together. # health() { - if [ $(consul info | awk '/num_peers/{print$3}') == 0 ]; then + local consul_args= + + if [ -z "${CONSUL}" ]; then + echo "CONSUL env was not defined." + exit 1 + fi + + ## TODO: read either: + # - CONSUL_TLS_PATH + # or + # - CONSUL_CACERT + # - CONSUL_CLIENT_CERT + # - CONSUL_CLIENT_KEY + # to prepare consul_args + # + # if [ -n "$CONSUL_TLS_PATH" ]; then + # consul_args="$consul_args -ca-file=$CONSUL_CACERT" + # consul_args="$consul_args -client-cert=$CONSUL_CLIENT_CERT" + # consul_args="$consul_args -client-key=$CONSUL_CLIENT_KEY" + # + # if [[ $CONSUL != "https"* ]]; then + # consul_args="$consul_args -http-addr=https://$CONSUL:8500" + # fi + # fi + + local info_output=$(consul info $consul_args) + + if [ -z "$info_output" ]; then + _log "Healtcheck failed while collecting info." + exit 1 + fi + + if [ $(echo $info_output | awk '/num_peers/{print$3}') == 0 ]; then _log "No peers in raft" - consul join ${CONSUL} + consul join $consul_args ${CONSUL} fi } @@ -82,7 +236,6 @@ _log() { echo " $(date -u '+%Y-%m-%d %H:%M:%S') containerpilot: $@" } - # # Defines $1 in the consul configuration as either an env or a default. # This basically behaves like ${!name_of_var} and ${var:-default} together diff --git a/ca/Dockerfile b/ca/Dockerfile new file mode 100644 index 0000000..ea026bf --- /dev/null +++ b/ca/Dockerfile @@ -0,0 +1,88 @@ +FROM alpine + +# directories +RUN mkdir -p /ssl /out + +# storage for signed certs +RUN touch /ssl/certindex + +# current cert serial number +RUN echo "000a" > /ssl/serial + +RUN apk --no-cache add openssl curl + +# root certificate +RUN openssl req -newkey rsa:2048 -days 3650 -x509 -nodes \ + -out /ssl/ca.crt \ + -keyout /ssl/privkey.pem \ + -subj "/C=/ST=/L=/O=/CN=consul" + +# ca config from http://russellsimpkins.blogspot.com/2015/10/consul-adding-tls-using-self-signed.html +# NOTE: authorityKeyIdentifier=keyid:always has been changed to authorityKeyIdentifier=keyid +# NOTE: _policy requirements other than commonName have been made optional +RUN echo $' \n\ +[ ca ] \n\ +default_ca = autopilotpattern \n\ + \n\ +[ crl_ext ] \n\ +# issuerAltName=issuer:copy #this would copy the issuer name to altname \n\ +authorityKeyIdentifier=keyid \n\ + \n\ +[ autopilotpattern ] \n\ +new_certs_dir = /tmp \n\ +unique_subject = no \n\ +certificate = /ssl/ca.crt \n\ +database = /ssl/certindex \n\ +private_key = /ssl/privkey.pem \n\ +serial = /ssl/serial \n\ +default_days = 365 \n\ +default_md = sha1 \n\ +policy = autopilotpattern_policy \n\ +x509_extensions = autopilotpattern_extensions \n\ + \n\ +[ autopilotpattern_policy ] \n\ +commonName = supplied \n\ +stateOrProvinceName = optional \n\ +countryName = optional \n\ +emailAddress = optional \n\ +organizationName = optional \n\ +organizationalUnitName = optional \n\ + \n\ +[ autopilotpattern_extensions ] \n\ +basicConstraints = CA:false \n\ +subjectKeyIdentifier = hash \n\ +authorityKeyIdentifier = keyid \n\ +keyUsage = digitalSignature,keyEncipherment \n\ +extendedKeyUsage = serverAuth,clientAuth \n\ +crlDistributionPoints = URI:http://path.to.crl/autopilotpattern.crl \n\ +' > /ssl/autopilotpattern.conf + +RUN echo $'#!/bin/sh \n\ +[ $# != 2 ] && { echo "Usage: ./gen_cert.sh " ; exit 1 ; } \n\ +[ -d /out/$1 ] && { echo "Error: destination directory /out/$1 already exists!" ; exit 2 ; } \n\ + \n\ +mkdir /out/$1 \n\ + \n\ +openssl req -newkey rsa:1024 -nodes -out /out/$1/$1.csr -keyout /out/$1/$1.key -subj "/C=/ST=/L=/O=/CN=$2" \n\ + \n\ +openssl ca -batch -config /ssl/autopilotpattern.conf -notext -in /out/$1/$1.csr -out /out/$1/$1.crt \n\ + \n\ +cp /ssl/ca.crt /out/$1/ca.crt \n\ +echo $2 > /out/$1/hostname \n\ +# verify "X509v3 Extended Key Usage" includes "TLS Web Server Authentication, TLS Web Client Authentication" \n\ +# openssl x509 -noout -text -in /out/server.crt \n\ +' > /ssl/gen_cert.sh && chmod +x /ssl/gen_cert.sh + + +# The Consul binary, used for generating a shared secret for encrypting gossip +ENV CONSUL_VERSION=1.0.0 +RUN export CONSUL_CHECKSUM=585782e1fb25a2096e1776e2da206866b1d9e1f10b71317e682e03125f22f479 \ + && export archive=consul_${CONSUL_VERSION}_linux_amd64.zip \ + && curl -Lso /tmp/${archive} https://releases.hashicorp.com/consul/${CONSUL_VERSION}/${archive} \ + && echo "${CONSUL_CHECKSUM} /tmp/${archive}" | sha256sum -c \ + && cd /bin \ + && unzip /tmp/${archive} \ + && chmod +x /bin/consul \ + && rm /tmp/${archive} + +ENTRYPOINT ["/ssl/gen_cert.sh"] diff --git a/etc/consul.hcl b/etc/consul.hcl index 1c79748..2f85302 100644 --- a/etc/consul.hcl +++ b/etc/consul.hcl @@ -4,6 +4,12 @@ data_dir = "/data" client_addr = "0.0.0.0" ports { dns = 53 + HTTP_PORT + HTTPS_PORT +} +addresses { + HTTP_ADDR + HTTPS_ADDR } recursors = ["8.8.8.8", "8.8.4.4"] disable_update_check = true diff --git a/etc/containerpilot.json5 b/etc/containerpilot.json5 index 0275745..24a95e7 100644 --- a/etc/containerpilot.json5 +++ b/etc/containerpilot.json5 @@ -30,7 +30,7 @@ }, { name: "preStop", - exec: ["consul", "leave"], + exec: ["/usr/local/bin/consul-manage", "preStop"], when: { source: "consul", once: "stopping" diff --git a/examples/compose/docker-compose.yml b/examples/compose/docker-compose.yml index 60133f0..635fc56 100644 --- a/examples/compose/docker-compose.yml +++ b/examples/compose/docker-compose.yml @@ -14,8 +14,15 @@ services: mem_limit: 128m ports: - 8500 + - 8501 environment: - CONSUL=consul - CONSUL_DATACENTER_NAME=dc1 + # The following variables can be used for HTTPS, RPC and gossip encryption + - CONSUL_TLS_PATH= + - CONSUL_ENCRYPT= + # see etc/consul-manage + - CONSUL_TLS_PRIVATE_HTTP= + command: > /usr/local/bin/containerpilot diff --git a/examples/triton-multi-dc/setup-multi-dc.sh b/examples/triton-multi-dc/setup-multi-dc.sh index 97e9919..0bec603 100755 --- a/examples/triton-multi-dc/setup-multi-dc.sh +++ b/examples/triton-multi-dc/setup-multi-dc.sh @@ -3,17 +3,36 @@ set -e -o pipefail help() { echo - echo 'Usage ./setup-multi-datacenter.sh [ [...]]' + echo 'Usage ./setup-multi-datacenter.sh [options] [ [...]]' echo echo 'Generates one _env file and docker-compose.yml file per triton profile, each of which' echo 'is presumably associated with a different datacenter.' + echo + echo 'This script accepts the following arguments:' + echo '-h/--help: Display this help' + echo '-g/--gossip-path : Path to a gossip key for inclusion in the environment file' + echo '-t/--tls-path : Path to configure for TLS certificates and key for later installation' + echo ' by setup-encryption.sh. NOTE: This is not a local path, this is CONSUL_TLS_PATH' + echo } -if [ "$#" -lt 1 ]; then +if [ "$#" -lt 1 ] || [ "$1" == "-h" ] || [ "$1" == "--help" ]; then help - exit 1 + exit 0 fi +tls_path= +gossip_path= + +# collect options for encryption +while true; do + case $1 in + -t|--tls-path) tls_path=$2 ; shift 2;; + -g|--gossip-path) gossip_path=$2 ; shift 2;; + *) break;; + esac +done + # --------------------------------------------------- # Top-level commands @@ -80,9 +99,23 @@ generate_env() { if [ ! -f "$output_file" ]; then echo '# Consul bootstrap via Triton CNS' >> $output_file echo CONSUL=consul.svc.${triton_account}.${triton_dc}.cns.joyent.com >> $output_file + + if [ -n "$tls_path" ]; then + echo "CONSUL_HTTP_SSL=true" >> $output_file + echo "CONSUL_HTTP_SSL_VERIFY=true" >> $output_file + echo "CONSUL_TLS_PATH=/ssl" >> $output_file + + echo "TLS configuration generated. Containers will await key material before booting." + fi + + if [ -n "$gossip_path" ]; then + echo "CONSUL_ENCRYPT=$(<$gossip_path)" >> $output_file + echo "Gossip encryption key loaded." + fi + echo >> $output_file else - echo "Existing _env file found at $1, exiting" + echo "Existing _env file found at $output_file, exiting" exit fi } diff --git a/examples/triton/setup.sh b/examples/triton/setup.sh index b7e7687..1211438 100755 --- a/examples/triton/setup.sh +++ b/examples/triton/setup.sh @@ -3,10 +3,16 @@ set -e -o pipefail help() { echo - echo 'Usage ./setup.sh' + echo 'Usage ./setup.sh [ check | help ] [options]' echo echo 'Checks that your Triton and Docker environment is sane and configures' echo 'an environment file to use.' + echo + echo 'This script accepts the following arguments:' + echo '-h/--help: Display this help' + echo '-g/--gossip-path : Path to a gossip key for inclusion in the environment file' + echo '-t/--tls-path : Path to directory containing CA/Client Certificates.' + echo } # populated by `check` function whenever we're using Triton @@ -19,6 +25,16 @@ TRITON_ACCOUNT= # Check for correct configuration and setup _env file check() { + local tls_path= + local gossip_path= + + while true; do + case $1 in + -t|--tls-path) tls_path=$2 ; shift 2;; + -g|--gossip-path) gossip_path=$2 ; shift 2;; + *) break;; + esac + done command -v docker >/dev/null 2>&1 || { echo @@ -46,6 +62,7 @@ check() { TRITON_USER=$(triton profile get | awk -F": " '/account:/{print $2}') TRITON_DC=$(triton profile get | awk -F"/" '/url:/{print $3}' | awk -F'.' '{print $1}') TRITON_ACCOUNT=$(triton account get | awk -F": " '/id:/{print $2}') + if [ ! "$docker_user" = "$TRITON_USER" ] || [ ! "$docker_dc" = "$TRITON_DC" ]; then echo tput rev # reverse @@ -75,9 +92,25 @@ check() { if [ ! -f "_env" ]; then echo '# Consul bootstrap via Triton CNS' >> _env echo CONSUL=consul.svc.${TRITON_ACCOUNT}.${TRITON_DC}.cns.joyent.com >> _env + + if [ -n "$tls_path" ]; then + echo "CONSUL_HTTP_SSL=true" >> _env + echo "CONSUL_HTTP_SSL_VERIFY=true" >> _env + echo "CONSUL_TLS_PATH=/ssl" >> _env + + echo "TLS configuration generated. Containers will await key material before booting." + fi + + if [ -n "$gossip_path" ]; then + echo "CONSUL_ENCRYPT=$(<$gossip_path)" >> _env + echo "Gossip encryption key loaded." + fi + echo >> _env + + echo "_env file generated. " else - echo 'Existing _env file found, exiting' + echo 'Existing _env file found at _env, exiting' exit fi } @@ -96,7 +129,14 @@ until shift 1 fi - $cmd "$@" + # most people don't expect to invoke a script like "./setup.sh help" + # so lets _help_ them out when they call us with -h/--help + if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then + help + exit 0 + fi + + check "$@" if [ $? == 127 ]; then help fi diff --git a/setup-encryption.sh b/setup-encryption.sh new file mode 100755 index 0000000..b1e43ee --- /dev/null +++ b/setup-encryption.sh @@ -0,0 +1,253 @@ +#!/bin/bash +set -e -o pipefail + +function help() { + echo "in help" + cat << 'EOF' +Usage: ./setup-encryption.sh [ build | generate | upload | help ] [options] + +--- + +setup-encryption.sh build: + Builds a container that boostraps a Certificate Authority and can be + invoked to generate certficates. This container should only be built + --image-name/-i : + The image name to tag when building the bootstrap CA container. + +setup-encryption.sh generate: + Invokes the bootstrap Certificate Authority container to generate a + new certificate to be used when encrypting RPC traffic. + --image-name/-i : + The image name to use when generating the certificate or key. Should + be the same as the argument specified to `build`. + --datacenter-name/-d : + The name of the Consul datacenter in which the certificate will be installed. + Generates a directory with the same name as the provided argument in the `secrets` directory + containing root and datacenter certificates in addition to a datacenter-specific key. + If this is omitted no certificate will be generated. + --hostname/-h : + Hostname under which Consul will be deployed to be included in the Common Name field of the + certificate. Defaults to "consul" is only appropriate in local deployments or deployments where + Consul will only be accessed from within a docker-compose network. Alternatively, specify + `--triton-profile/-t` to generated the name using Triton CNS. + --triton-profile/-t : + Name of triton profile for\ automatic hostname configuration based on Triton CNS. + --gossip/-g : + Name of file to generate the Consul gossip shared key. Will be placed in the `secrets` + directory. If this is omitted no gossip key will be generated. + +setup-encryption.sh upload: + Uploads tokens into remote Consul instaces that are awaiting TLS files. + --compose-file/-f : + Path to docker-compose.yml file. Passed to docker-compose -f argument. + --compose-project-name/-p : + Project name used by docker-compose. Passed to docker-compose -p argument. + --service-name/-s : + Name of docker-compose service to filter on when querying container IDs. Defaults to "consul" + --datacenter-name/-d : + The name of the Consul datacenter passed to `generate`. Should reference + a directory in the `secrets` directory containing: + + ca.crt + \$DATACENTER.key + \$DATACENTER.crt + --tls-path/-t : + The value provided to `CONSUL_TLS_PATH` where the key content is expected. Defaults to "/ssl" +EOF +} + +# +# Build the bootstrap CA container. Useful for testing. STDIN is used +# to eliminate build context. +# +function build() { + image_name= + + while true; do + case $1 in + -i | --image-name ) image_name=$2; shift 2;; + *) break;; + esac + done + + if [ -z "$image_name" ]; then + echo "Image name must be provided" + exit 1 + fi + + docker build - < ca/Dockerfile -t "$image_name" +} + +# +# Use the bootstrap CA container to generate a certificate, a gossip shared key, or both. +# +function generate() { + + if [ ! -d ./secrets ]; then + mkdir ./secrets + fi + + local image_name= + local datacenter_name= + local target_hostname= + local triton_profile= + local gossip_key_file= + + while true; do + case $1 in + -i | --image-name ) image_name=$2; shift 2;; + -d | --datacenter-name ) datacenter_name=$2; shift 2;; + -h | --hostname ) target_hostname=$2; shift 2;; + -t | --triton-profile ) triton_profile=$2; shift 2;; + -g | --gossip ) gossip_key_file=$2; shift 2;; + *) break;; + esac + done + + if [ -z "$image_name" ]; then + echo "Image name must be provided" + exit 1 + fi + + if [ ! $(docker inspect "$image_name" &>/dev/null && echo $?) ]; then + echo "Image specified ($image_name) does not exist" + exit 1 + fi + + if [ -f ./secrets/gossip ] && [ -n "$gossip_key_file" ]; then + echo "Gossip key generation requested but key already exists at ./secrets/gossip" + exit 1 + fi + + if [ -z "$datacenter_name$gossip_key_file" ]; then + echo "Not enough arguments to generate, need --datacenter-name/-d and/or --gossip/-g" + exit 1 + fi + + if [ -n "$target_hostname" ] && [ -n "$triton_profile" ]; then + echo "Error, both target hostname and triton profile were specified." + exit 1 + elif [ -z "$target_hostname" ] && [ -z "$triton_profile" ]; then + target_hostname=consul + elif [ -n "$triton_profile" ]; then + # TODO: calculate hostname from triton profile: + + echo not yet + exit 1 + + TRITON_DC=$(triton profile get $triton_profile | awk -F"/" '/url:/{print $3}' | awk -F'.' '{print $1}') + TRITON_ACCOUNT=$(TRITON_PROFILE=$triton_profile triton account get | awk -F": " '/id:/{print $2}') + + local triton_cns_enabled=$(TRITON_PROFILE=$triton_profile triton account get | awk -F": " '/cns/{print $2}') + if [ ! "true" == "$triton_cns_enabled" ]; then + echo + tput rev # reverse + tput bold # bold + echo 'Error! Triton CNS is required and not enabled.' + tput sgr0 # clear + echo + exit 1 + fi + + target_hostname=consul.svc.${TRITON_ACCOUNT}.${TRITON_DC}.cns.joyent.com + fi + + if [ -n "$datacenter_name" ]; then + echo "Generating certificates for datacenter $datacenter_name" + + docker run -it --rm -v $(PWD)/secrets:/out "$image_name" $datacenter_name $target_hostname + output_dir="$(PWD)/secrets/$datacenter_name" + + if ! $(find "$output_dir/ca.crt" "$output_dir/$datacenter_name.crt" "$output_dir/$datacenter_name.key" &>/dev/null); then + echo "Error occurred while generating certificates or key!" + exit 1 + fi + + echo "Certificates saved at $output_dir" + fi + + if [ -n "$gossip_key_file" ]; then + gossip_key_file="$(PWD)/secrets/$gossip_key_file" + + if [ -f "$gossip_key_file" ]; then + echo "Gossip key file already exists at $gossip_key_file" + exit 1 + else + docker run -it --rm --entrypoint /bin/consul "$image_name" keygen > "$gossip_key_file" + + echo "Gossip key saved at $gossip_key_file" + fi + fi +} + + +function upload() { + + local compose_file=${COMPOSE_FILE:-${COMPOSE_FILE:-docker-compose.yml}} + local compose_project_name=${COMPOSE_PROJECT_NAME:-$(basename $PWD)} + local service_name=consul + local datacenter_name= + local tls_path=/ssl + + while true; do + case $1 in + -f | --compose-file ) compose_file=$2; shift 2;; + -p | --compose-project-name ) compose_project_name=$2; shift 2;; + -s | --service-name ) service_name=$2; shift 2;; + -d | --datacenter-name ) datacenter_name=$2; shift 2;; + -t | --tls-path ) tls_path=$2; shift 2;; + *) break;; + esac + done + + if [ ! -f "$compose_file" ]; then + echo "docker-compose file not found: $compose_file" + exit 1 + fi + + local_tls_path="$PWD/secrets/$datacenter_name" + echo "Checking for certificates and key in $local_tls_path" + + if [ ! -d "$local_tls_path" ] \ + || [ ! -f "$local_tls_path/ca.crt" ] \ + || [ ! -f "$local_tls_path/$datacenter_name.crt" ] \ + || [ ! -f "$local_tls_path/$datacenter_name.key" ]; then + echo "Missing files in $local_tls_path. Check that the following files exist:" + echo "Root certificate: $local_tls_path/ca.crt" + echo "Client certificate: $local_tls_path/$datacenter_name.crt" + echo "Client key: $local_tls_path/$datacenter_name.key" + + exit 1 + fi + + local container_ids=$(docker-compose -f $compose_file -p $compose_project_name ps -q $service_name) + + if [ -z "$container_ids" ]; then + echo "No containers found! Project name: $compose_project_name, file: $compose_file" + exit 1 + fi + + echo "Uploading key material to container IDs: ${container_ids[@]}" + + for container_id in $container_ids; do + echo "Uploading key material from $local_tls_path to container ID: [$container_id] path: [$tls_path]" + + docker cp $local_tls_path $container_id:$tls_path + done + + echo Successfully uploaded TLS certificates and key. +} + +while true; do + case $1 in + build | generate | upload | help) cmd=$1; shift; break;; + *) break;; + esac +done + +if [ -z $cmd ]; then + help + exit +fi + +$cmd $@