From e2622959a2234c7679c5c3eb18ba8642a1ce3a13 Mon Sep 17 00:00:00 2001 From: Paul Duncan Date: Sun, 17 Oct 2021 16:44:32 -0400 Subject: add bin/hook --- bin/hook/README.md | 15 +++++ bin/hook/deploy.rb | 153 +++++++++++++++++++++++++++++++++++++++++++ bin/hook/etc/README.md | 12 ++++ bin/hook/etc/webhook.conf | 34 ++++++++++ bin/hook/etc/webhook.service | 14 ++++ bin/hook/fire.rb | 54 +++++++++++++++ 6 files changed, 282 insertions(+) create mode 100644 bin/hook/README.md create mode 100755 bin/hook/deploy.rb create mode 100644 bin/hook/etc/README.md create mode 100644 bin/hook/etc/webhook.conf create mode 100644 bin/hook/etc/webhook.service create mode 100755 bin/hook/fire.rb (limited to 'bin') diff --git a/bin/hook/README.md b/bin/hook/README.md new file mode 100644 index 0000000..aebe5c6 --- /dev/null +++ b/bin/hook/README.md @@ -0,0 +1,15 @@ +Webhook Scripts +=============== + +These scripts are used to fire and handle webhooks. + +* `fire.rb`: Send `POST` request to webhook at given `HOOK_URL` webhook + with a [SHA256][] [HMAC][] signature in `X-Hub-Signature` header. + Configuration is handled by the `HOOK_URL` and `HOOK_HMAC_KEY` + environment variables. +* `deploy.rb`: Verify time in from payload body (to prevent replay + attacks), execute `git pull`, execute `hugo --minify`, and finally + update the `htdocs` symlink. + +[sha256]: https://en.wikipedia.org/wiki/SHA-2 "SHA256" +[hmac]: https://en.wikipedia.org/wiki/HMAC "Hashed Message Authentication Code" diff --git a/bin/hook/deploy.rb b/bin/hook/deploy.rb new file mode 100755 index 0000000..20b3523 --- /dev/null +++ b/bin/hook/deploy.rb @@ -0,0 +1,153 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# deploy.rb: Site deployment script which does the following: +# +# 1. Verifies that the timestamp passed in RFC3339 format as the first +# command-line argument is within 5 minutes of the current timestamp +# (to prevent replay attacks). +# 2. Locks the repo directory with `flock` to prevent conflicts. +# 3. Executes `git pull` on the repo directory to pull any upstream +# changes. +# 4. Executes `hugo --minify -d ...` to build the site to a unique +# destination in the `builds' directory. +# 5. Updates the `htdocs` symlink to point at the destination directory. +# 6. Removes expired builds from the `DEPLOY_BUILDS_DIR` directory. +# +# Configuration is handled with the following environment variables: +# +# * `DEPLOY_HTDOCS_PATH`: Required. Absolute path to `htdocs` symlink. +# * `DEPLOY_REPO_DIR`: Required. Absolute path to clone of upstream Git +# repository +# * `DEPLOY_BUILDS_DIR`: Required. Absolute path to directory for site +# builds. +# * `DEPLOY_SKEW_THRESHOLD`: Optional, defaults to 300 (5 minutes) if +# unspecified. Amount of clock skew to allow when verifying payload +# timestamp. +# * `DEPLOY_BUILD_CACHE_SIZE`: Optional, defaults to 5 if unspecified. +# Number of old site builds to keep in the builds directory. +# + +# load libraries +require 'openssl' +require 'json' +require 'time' +require 'fileutils' + +# pass to htdocs symlink +HTDOCS_PATH = ENV.fetch('DEPLOY_HTDOCS_PATH') + +# absolute path to source directory (e.g., git repo clone) +SRC_DIR = ENV.fetch('DEPLOY_REPO_DIR') + +# get absolute path to builds directory from environment +BUILDS_DIR = ENV.fetch('DEPLOY_BUILDS_DIR') + +# verify that the timestamp from the command-line is within the given +# amount of time of the current system timestamp +NUM_SECONDS = ENV.fetch('DEPLOY_SKEW_THRESHOLD', '300').to_i + +# number of site builds to keep in builds directory +BUILD_CACHE_SIZE = ENV.fetch('DEPLOY_BUILD_CACHE_SIZE', '5').to_i + +# get current timestamp +NOW = Time.now +NOW_S = NOW.strftime('%Y%m%d-%H%M%S') + +# build random suffix +SUFFIX = OpenSSL::Digest::SHA256.hexdigest(OpenSSL::Random.random_bytes(32)) + +# destination directory +DST_DIR = '%s/site-%s-%s' % [BUILDS_DIR, NOW_S, SUFFIX] + +# +# Execute block and return the amount of time, in seconds, that the +# blook took to execute. +# +def timed(&block) + # get start time + t0 = Time.now + + # call block + block.call + + # return time taken, in seconds + Time.now - t0 +end + +# +# print and exec command. +# +def run(*cmd) + # print command + puts 'run: ' + JSON(cmd) + + # run command + system(*cmd, exception: true) +end + +# check command-line argument +raise 'missing RFC3339-formatted timestamp argument' unless ARGV.size > 0 + +# check timestamp +hook_time = Time.parse(ARGV.first) +delta = (NOW - hook_time).abs +if delta > NUM_SECONDS + raise JSON({ + message: 'payload time delta exceeds threshold', + hook_time: hook_time.iso8601, + current_time: NOW.iso8601, + threshold: NUM_SECONDS, + delta: delta, + }) +end + +Dir.chdir(SRC_DIR) +pull_time = timed do + # update git clone with lock + # note: we don't technically need '--rebase', but it silences a git + # default merge strategy warning + run('/usr/bin/flock', SRC_DIR, '/usr/bin/git', 'pull', '--rebase') +end + +# build site +hugo_time = timed do + run('/usr/bin/hugo', '--minify', '-d', DST_DIR) +end + +link_time = timed do + # update htdocs symlink + File.unlink(HTDOCS_PATH) if File.symlink?(HTDOCS_PATH) + File.symlink(DST_DIR, HTDOCS_PATH) +end + +rm_time = timed do + # get sorted list of builds + builds = Dir.entries(BUILDS_DIR).select { |name| name !~ /\A\./ }.sort + + # have we exceeded the cache size threshold? + if builds.size > BUILD_CACHE_SIZE + # get list of builds to remove + rms = builds.take(builds.size - BUILD_CACHE_SIZE) + puts 'rms: ' + JSON(rms) + + rms.map do |name| + Thread.new (name) do + # build absolute path to old build + path = File.join(BUILDS_DIR, name) + + # remove old build directory + FileUtils.rm_rf(path) + end + end.each { |th| th.join } + end +end + +# print timing information +puts 'times: ' + JSON({ + total: Time.now - NOW, + pull: pull_time, + hugo: hugo_time, + rm: rm_time, +}) diff --git a/bin/hook/etc/README.md b/bin/hook/etc/README.md new file mode 100644 index 0000000..e918e07 --- /dev/null +++ b/bin/hook/etc/README.md @@ -0,0 +1,12 @@ +Webhook Config Files +==================== + +Various [webhook][] daemon configuration files. The files are as +follows: + +* `webhook.service`: [systemd][] service file for [webhook][] daemon +* `webhook.conf`: [webhook][] config file ([JSON][]-formatted). + +[systemd]: https://en.wikipedia.org/wiki/Systemd "Linux system management daemon" +[webhook]: https://github.com/adnanh/webhook "minimal webhook daemon" +[json]: https://json.org "JavaScript Object Notation" diff --git a/bin/hook/etc/webhook.conf b/bin/hook/etc/webhook.conf new file mode 100644 index 0000000..95c4ca5 --- /dev/null +++ b/bin/hook/etc/webhook.conf @@ -0,0 +1,34 @@ +[{ + "id": "deploy-pablotron-org", + "execute-command": "/data/www/pablotron.org/bin/hook/deploy.rb", + + "pass-arguments-to-command": [{ + "source": "payload", + "name": "time" + }], + + "pass-environment-to-command": [{ + "source": "string", + "envname": "DEPLOY_HTDOCS_PATH", + "name": "/data/www/pablotron.org/htdocs" + }, { + "source": "string", + "envname": "DEPLOY_REPO_DIR", + "name": "/data/www/pablotron.org/data/git/pablotron.org" + }, { + "source": "string", + "envname": "DEPLOY_BUILDS_DIR", + "name": "/data/www/pablotron.org/data/builds" + }], + + "trigger-rule": { + "match": { + "type": "payload-hmac-sha256", + "secret": "omitted", + "parameter": { + "source": "header", + "name": "X-Hub-Signature" + } + } + } +}] diff --git a/bin/hook/etc/webhook.service b/bin/hook/etc/webhook.service new file mode 100644 index 0000000..20cd6bb --- /dev/null +++ b/bin/hook/etc/webhook.service @@ -0,0 +1,14 @@ +[Unit] +Description=Webhook daemon +After=network.target +ConditionPathExists=/etc/webhook.conf + +[Service] +# debian/ubuntu version of webhook is comically old, so i'm using the static +# binary from the github releases page +# ExecStart=/usr/local/bin/webhook -port 9000 -hooks /etc/webhook.conf -urlprefix '' -verbose +ExecStart=/usr/local/bin/webhook -port 9000 -hooks /etc/webhook.conf -urlprefix '' +RunAs=webhook + +[Install] +WantedBy=multi-user.target diff --git a/bin/hook/fire.rb b/bin/hook/fire.rb new file mode 100755 index 0000000..5cd6022 --- /dev/null +++ b/bin/hook/fire.rb @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# bin/hook/fire.rb: trigger deploy webhook. +# +# Requires that the following environment variables are set: +# +# * HOOK_URL: Webhook URL +# * HOOK_HMAC_KEY: Hook HMAC-SHA256 key. +# + +require 'openssl' +require 'json' +require 'time' +require 'uri' +require 'net/http' +require 'pp' + +# get hook URL and HMAC key +hook_url = ENV.fetch('HOOK_URL') +key = ENV.fetch('HOOK_HMAC_KEY') + +# get name of HMAC header, or default to X-Hub-Signature if unspecified +header_name = ENV.fetch('HOOK_HMAC_HEADER', 'X-Hub-Signature') + +# build body +body = JSON({ + # current timestamp, in ISO8601/RFC3339 format + time: Time.now.iso8601, + + # random nonce + nonce: OpenSSL::Digest::SHA256.hexdigest(OpenSSL::Random.random_bytes(32)), +}) + +# calculate hmac +hmac = OpenSSL::HMAC.hexdigest('SHA256', key, body) + +# parse uri +uri = URI.parse(hook_url) + +# build request +req = Net::HTTP::Post.new(uri.path) +# req[header_name] = hmac +req[header_name] = 'sha256=%s' % [hmac] +req['Content-Type'] = 'application/json' +req.body = body + +# build connection +http = Net::HTTP.new(uri.host, uri.port) +http.use_ssl = true + +# send request +pp http.request(req) -- cgit v1.2.3