diff --git a/.gitignore b/.gitignore index 3c3629e..d9b1d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +SECRET diff --git a/Dockerfile b/Dockerfile index 64d8391..26f4c5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.9 as build +FROM alpine:3.9 RUN apk add --no-cache make nodejs npm @@ -6,7 +6,8 @@ WORKDIR /root COPY package*.json ./ RUN npm set progress=false && npm config set depth 0 RUN npm install --only=production - COPY src /root/src + EXPOSE 3000 -CMD ["npm", "start"] +ENV TERM xterm-256color +CMD ["node", "/root/src/server.js"] diff --git a/Makefile b/Makefile index b87eb8f..e7c9600 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -REGISTRY_HOST = docker.io +REGISTRY = docker.io USERNAME = expelledboy NAME = $(shell basename $(CURDIR)) -IMAGE = $(REGISTRY_HOST)/$(USERNAME)/$(NAME) +IMAGE = $(REGISTRY)/$(USERNAME)/$(NAME) .EXPORT_ALL_VARIABLES: .DEFAULT: help @@ -12,13 +12,15 @@ IMAGE = $(REGISTRY_HOST)/$(USERNAME)/$(NAME) help: ## Print help messages @sed -n 's/^\([a-zA-Z_-]*\):.*## \(.*\)$$/\1 -- \2/p' Makefile +build: VERSION = $(shell git describe --always) build: ## Build docker image docker build -t $(IMAGE) . + docker tag $(IMAGE):latest $(IMAGE):$(VERSION) test: ## Run simple unit test docker run -d --rm \ --name $(NAME) \ - -v $(PWD):/webhook/ \ + -v $(PWD):/webhooks/ \ -p 3000:3000 \ $(IMAGE) sleep 5 @@ -42,8 +44,43 @@ bump: bump-package ## Bump release version eg. `make IMPACT=patch bump` git tag $(VERSION) publish: VERSION = $(shell git describe --always) -publish: on-tag build ## Push docker image to $(REGISTRY_HOST) - echo docker push $(IMAGE):$(VERSION) - echo docker push $(IMAGE):latest +publish: on-tag build ## Push docker image to $(REGISTRY) + docker push $(IMAGE):$(VERSION) + docker push $(IMAGE):latest # == +.PHONY: web intro start-webhooks hello-world crash run-webhook + +intro: + @cat ./docs/welcome.md + +web: ## Run webhooks interactively in docker. + docker run -it --rm \ + --name $(NAME) \ + --volume $(PWD):/webhooks \ + --publish 80:3000 \ + expelledboy/make-webhooks + +start-webhooks: env-HOSTNAME ## Deploy as service into docker swarm with traefik. + docker service create \ + --name make-webhooks \ + --mode global \ + --network web \ + --mount src="$(PWD)",target=/webhooks,type=bind \ + --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ + --label traefik.enable=true \ + --label traefik.docker.network=web \ + --label traefik.port=3000 \ + --label traefik.frontend.rule=Host:$(HOSTNAME) \ + $(IMAGE) + +hello-world: GREET ?= "World" +hello-world: ## Example target using environment variables. + @echo "Hello, $(GREET)!" + +crash: ## Target that always fails. + exit 10 + +run-webhook: HOOK = hello-world +run-webhook: ## Run webhook from Makefile and fail on error + curl -fs http://localhost/$(HOOK) diff --git a/README.md b/README.md index 6217ab6..7de80f8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,64 @@ -# Makefile Hook +# make-webhooks -Webhooks for your project made simple. Most projects have a Makefile, why not -just expose an API to run them? +> Just try it: `make web`. ;) +You are now exposing this projects `Makefile` to the web! + +> $ curl http://localhost/hello-world +> {"status":0} + +Change environment variables. All vars are uppercased by default. +Dont forget HTTP URL encoding! + +> curl http://localhost/hello-world\?GREET\=Anthony +> curl "http://localhost/hello-world?GREET=Anthony" +> curl "http://localhost/hello-world?greet=Anthony" + +You can use the webhooks return status as an exit code in another script. + +```sh +#!/bin/bash + +if [ "$1" -eq "run-webhook" ]; then + exit $(curl -s http://localhost/crash | jq '.status') +fi ``` -echo 'test:\n\texit 0' > Makefile -docker run -d --rm \ - -v $(PWD):/webhook \ - -p 3000:3000 \ - --name make-webhook \ - expelledboy/make-webhook -curl http://localhost:3000/test - -# Cleanup -docker stop make-webhook + +> ./script.sh; echo $? +> 0 +> ./script.sh run-webhook; echo $? +> 2 + +Example usage in the command line. + +> `exit $(curl -s http://localhost/crash | jq '.status')`; echo $? +> 2 + +But best yet, in a make target! + +```make +run-webhook: + @curl -fs http://localhost/crash ``` +> $ make run-webhook +> make: *** [Makefile:86: run-webhook] Error 22 + +### Security + +We match the Bearer token in the Authorization header, either with environment +variable `SECRET`, or the contents of the file located at `/webhooks/SECRET`. + +> $ echo mySecret > SECRET +> $ curl http://localhost/hello-world +> Unauthorized +> $ curl -H 'Authorization: Bearer mySecret' http://localhost/hello-world +> {"status":0} + +Can even do key rotations! +> $ echo -n myNewSecret > SECRET +> $ curl -H 'Authorization: Bearer mySecret' http://localhost/hello-world ; echo '' +> Unauthorized +> $ curl -H 'Authorization: Bearer myNewSecret' http://localhost/hello-world ; echo '' +> {"status":0} diff --git a/docs/exit-on-status.sh b/docs/exit-on-status.sh new file mode 100755 index 0000000..fb384e5 --- /dev/null +++ b/docs/exit-on-status.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +if [ "$1" == "run-webhook" ]; then + exit $(curl -s http://localhost/crash | jq '.status') +fi diff --git a/docs/welcome.md b/docs/welcome.md new file mode 100644 index 0000000..0d3be60 --- /dev/null +++ b/docs/welcome.md @@ -0,0 +1,5 @@ + +You are now exposing this projects `Makefile` to the web! + +> $ curl http://localhost/hello-world +> {"status":0} diff --git a/package.json b/package.json index 5f60151..fbae842 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "make-webhook", + "name": "make-webhooks", "version": "1.0.2", "description": "Run make targets through webhooks", - "repository": "https://github.com/expelledboy/make-webhook", + "repository": "https://github.com/expelledboy/make-webhooks", "author": "expelledboy", "license": "Apache-2.0", "main": "src/server.js", @@ -34,7 +34,7 @@ }, "scripts": { "start": "node src/server.js", - "dev": "DEBUG=webhook:* nodemon src/server.js", + "dev": "DEBUG=webhooks:* nodemon src/server.js", "lint": "eslint src", "lint:fix": "npm run lint -- --fix" } diff --git a/src/server.js b/src/server.js index e76e092..bdd5b8f 100644 --- a/src/server.js +++ b/src/server.js @@ -1,43 +1,83 @@ const fs = require("fs"); const path = require("path"); const express = require("express"); +const process = require("process"); const { exec } = require("child_process"); +// Constants const HOST = "0.0.0.0"; -const PORT = parseInt(process.env.PORT || "3000", 10); -const { TOKEN } = process.env; -const BASE_DIR = process.env.BASE_DIR || "/webhook"; -const MAKEFILE = `${BASE_DIR}/Makefile`; +const PORT = 3000; +const MAKE_DIR = process.env.MAKE_DIR || "/webhooks"; +const MAKEFILE = `${MAKE_DIR}/Makefile`; +const SECRET_FILE = `${MAKE_DIR}/SECRET`; const app = express(); -if (TOKEN) - app.use((req, res, next) => { - if (req.headers.authorization !== `Bearer ${TOKEN}`) - return res.status(401).send("Unauthorized"); - return next(); - }); +// Get hot reloaded secret +const getSecret = () => { + if (fs.existsSync(SECRET_FILE)) { + return fs.readFileSync(SECRET_FILE); + } + return process.env.SECRET; +}; +// Security +app.use((req, res, next) => { + const secret = getSecret(); + if (secret && req.headers.authorization !== `Bearer ${secret}`) { + return res.status(401).send("Unauthorized"); + } + return next(); +}); + +// Sanity check if (!fs.existsSync(MAKEFILE)) { console.error(`${MAKEFILE} does not exists`); process.exit(1); } +// Print an optional container introduction on startup +process.nextTick(() => { + const opts = { cwd: MAKE_DIR }; + exec(`make intro`, opts, (error, stdout) => console.log(stdout)); +}); + +// All GET requests app.get("*", (req, res) => { + // Parse URL const { name: target } = path.parse(req.path); - const config = { env: req.query, cwd: BASE_DIR }; + const ENV = Object.entries(req.query).reduce((acc, [key, value]) => { + acc[key.toUpperCase()] = value; + return acc; + }, {}); - exec(`make ${target}`, config, (error, stdout, stderr) => { + // Marshal command results into json + const handleExec = (error, stdout, stderr) => { if (error) { - console.error(new Date(), { target, env: req.query, status: error.code }); - return res.status(500).send(stderr); + const response = { target, env: ENV, status: error.code }; + console.log(stderr); + return res.status(500).json(response); } + console.log(stdout); + return res.json({ status: 0 }); + }; - console.log(new Date(), { status: 0 }); - return res.send("OK"); - }); + // Execute make target + const opts = { env: ENV, cwd: MAKE_DIR }; + const command = `make ${target}`; + const env = Object.entries(ENV) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + console.log("## ==", new Date(), env, command); + exec(command, opts, handleExec); }); +// Start HTTP server app.listen(PORT, HOST); +console.log(`# Running at http://${HOST}:${PORT}`); -console.log(`==> Running at http://localhost:${PORT}`); +// Terminate with Ctrl-C +process.on("SIGINT", () => { + console.log("# Interrupted"); + process.exit(0); +});