2017 03 30 Semi-automatic HTTPS certs for GitLab Pages

Firefox indicating secure HTTPS connection

Let’s kick this off with a nice simple one; while I was choosing hosting for the current incarnation of this site, I found that GitLab Pages allow you to supply a certificate to serve the site using HTTPS. I ended up choosing GitLab for this, and a few other reasons, so here’s how I got a shiny green padlock to adorn my site URL.

Assuming I’ve not been ignoring the Let’s Encrypt cert expiration emails, you should be able to access this site using HTTPS; if you’re not already doing so, give it a try. Your browser should inform you (as seen above) that the connection to the site is encrypted and that the identity of the site is certified by Let’s Encrypt. Let’s Encrypt is a free Certificate Authority (CA) that automatically issues certs for anyone that requests them, on the condition that they can prove they have control of the domains they’re requesting certs for.

The intended use of Let’s Encrypt is to configure the web server itself to automatically request the certs it needs, whenever they need renewing. To this end, many of the (numerous) available clients focus on automation and integration. Since I don’t have control of (or want responsibility for) my web server, this isn’t useful for my particular setup. What I’m looking for is the simplest and easiest process to manually generate certs that I can paste into GitLab’s configuration pages.

Since I am almost exclusively using Windows and I upload to my web server by pushing direct to my GitLab site repository, I started by looking at shell script based clients – hoping to be able to run one in Git for Windows’ MSYS. I settled on dehydrated because it runs perfectly happily in MSYS with only minor modifications, does not require root access or any installation procedure, and allows the user to specify a hook script to deploy challenge files to their server.

That last part is critical; when requesting certs from Let’s Encrypt, it will issue a challenge, requiring the client to host a challenge file at a specific HTTP address under the domain in question (there is another challenge mode that requires updating DNS records, but I have no means of automating that, so we’ll ignore it). I wrote a script (see below) to write these challenge files (and automatically push them to GitLab) in a format that Jekyll will process correctly.

For convenience, I threw the script and my config files for dehydrated (see their documentation for details) into a “letsencrypt” git repository, and pulled in the source for dehydrated itself (which, again, is self contained and does not need to be built or installed) as a git submodule to simplify setup and track the version I’m using. With this arrangement, it’s a dead simple process to get new certs on any machine where I have access to the site git repository:

  1. Clone the “letsencrypt” repository:

     ~/projects/web
     $ git clone --recurse-submodules git@gitlab.com:haddoncd/letsencrypt.git
     Cloning into 'letsencrypt'...
    
     ~/projects/web
     $ cd letsencrypt/
    
  2. Generate a new account key:

     ~/projects/web/letsencrypt
     $ dehydrated/dehydrated --register --accept-terms
     # INFO: Using main config file /c/Users/Haddon/projects/web/letsencrypt/config
     + Generating account key...
     + Registering account key with ACME server...
     + Done!
    
  3. Start the certificate request process:

     ~/projects/web/letsencrypt
     $ dehydrated/dehydrated -c
     # INFO: Using main config file /c/Users/Haddon/projects/web/letsencrypt/config
     Processing haddon.org.uk with alternative names: www.haddon.org.uk
    
  4. At this point, the script will push some changes to the website and wait for us:

     hook: acme-challenge pushed, please wait for jekyll to deploy
     Press enter to continue...
    

    Let’s head over to GitLab see if it’s deploying:

     Running with gitlab-ci-multi-runner 1.11.1 (a67a225)
       on docker-auto-scale (4e4528ca)
     Using Docker executor with image ruby:2.1 ...
     Pulling docker image ruby:2.1 ...
     Running on runner-4e4528ca-project-2108767-concurrent-0 via runner-4e4528ca-machine-1490786569-856e1e7f-digital-ocean-2gb...
     Cloning repository...
     Cloning into '/builds/haddoncd/haddoncd.gitlab.io'...
     Checking out 3922b597 as master...
     Skipping Git submodules setup
     $ gem install jekyll
     Successfully installed public_suffix-2.0.5
     Successfully installed addressable-2.5.0
     Successfully installed colorator-1.1.0
     Successfully installed sass-3.4.23
     Successfully installed jekyll-sass-converter-1.5.0
     Successfully installed rb-fsevent-0.9.8
     Building native extensions.  This could take a while...
    

    Right. I’ll go get a drink.

     Successfully installed ffi-1.9.18
     Successfully installed rb-inotify-0.9.8
     Successfully installed listen-3.0.8
     Successfully installed jekyll-watch-1.5.0
     Successfully installed kramdown-1.13.2
     Successfully installed liquid-3.0.6
     Successfully installed mercenary-0.3.6
     Successfully installed forwardable-extended-2.6.0
     Successfully installed pathutil-0.14.0
     Successfully installed rouge-1.11.1
     Successfully installed safe_yaml-1.0.4
     Successfully installed jekyll-3.4.3
     18 gems installed
     $ jekyll build -d public/
     Configuration file: /builds/haddoncd/haddoncd.gitlab.io/_config.yml
                 Source: /builds/haddoncd/haddoncd.gitlab.io
            Destination: public/
      Incremental build: disabled. Enable with --incremental
           Generating...
                         done in 0.069 seconds.
      Auto-regeneration: disabled. Use --watch to enable.
     Uploading artifacts...
     public: found 13 matching files
     Uploading artifacts to coordinator... ok            id=13222812 responseStatus=201 Created token=MB5LZp66
     Job succeeded
    

    Ah, it’s finished. Time to hit enter back in our terminal. GitLab says that only took 1 minute 7 seconds; which is just shy of a thousand times longer than Jekyll took to generate the HTML. At least I won’t be compelled to spend time getting the HTML generation to run fast!

  5. After repeating step 4 for each other domain being requested, dehydrated should report success, meaning our certificate files have been written to the certs directory:

      + Requesting certificate...
      + Checking certificate...
      + Done!
      + Creating fullchain.pem...
     hook: Not deploying certs...
      + Done!
    
  6. Now we just need to delete our existing domains in the GitLab pages configuration, and add each of them back with the new cert:

GitLab "New Pages Domain" form

Appendix: Hook script

Here’s the hook script I use for deploying challenge files to a Jekyll git repository.

#!/usr/bin/env bash

# WARNING: This script will make and push commits on the current branch, then
#          use git push -f to rewind them. Use at your own risk.

# TODO:
# - Doesn't check to make sure we're on the appropriate branch before committing
#   and pushing.
# - Could be more economical about pushing the head of master around to avoid
#   unneccessary site rebuilds ... but you can just hit the cancel button in
#   GitLab.

repo="/path/to/the/gitlab/page/repo"

if [ ! -d "${repo}" ] ; then
  echo "${0}: No such dir - ${repo}" >&2
  exit 1
fi

cd "${repo}"

if ! git rev-parse > /dev/null 2>&1 ; then
  echo "${0}: Not a git repo - ${repo}" >&2
  exit 1
fi

if [ -n "$(git status --porcelain)" ]; then
  echo "${0}: git repo not clean, bailing out!" >&2
  exit 1
fi

mode="$1"

if [ "${mode}" = "exit_hook" ] ; then
  # nothing to do
  exit 0
elif [ "${mode}" = "unchanged_cert" ] ; then
  # nothing to do
  exit 0
elif [ "${mode}" = "deploy_cert" ] ; then
  echo "${0}: Not deploying certs..." >&2
  exit 0
fi

altname="$2"
token_filename="$3"
token_content="$4"

if [ "${mode}" = "deploy_challenge" ] ; then
  if [ -f acme-challenge ] ; then
    echo "${0}: acme-challenge file already exists, bailing out!" >&2
    exit 1
  fi
  echo "---"                                                      >> acme-challenge
  echo "permalink: /.well-known/acme-challenge/${token_filename}" >> acme-challenge
  echo "---"                                                      >> acme-challenge
  echo ""                                                         >> acme-challenge
  echo "${token_content}"                                         >> acme-challenge
  git add acme-challenge
  git commit -m "acme-challenge ${altname} ${token_filename} ${token_content}"
  git push
  echo "${0}: acme-challenge pushed, please wait for jekyll to deploy"
  echo 'Press enter to continue...'
  read -rs
elif [ "${mode}" = "clean_challenge" ] ; then
  if [ "$(git show -s --format=%B)" != "acme-challenge ${altname} ${token_filename} ${token_content}" ] ; then
    echo "${0}: git HEAD isn't right, don't want to rewind!" >&2
    exit 1
  fi
  git reset --hard HEAD~1
  git push -f
else
  echo "${0}: Unexpected mode - ${mode}" >&2
  exit 1
fi