aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bin/hook/README.md15
-rwxr-xr-xbin/hook/deploy.rb153
-rw-r--r--bin/hook/etc/README.md12
-rw-r--r--bin/hook/etc/webhook.conf34
-rw-r--r--bin/hook/etc/webhook.service14
-rwxr-xr-xbin/hook/fire.rb54
6 files changed, 282 insertions, 0 deletions
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)