diff --git a/.ackrc b/.ackrc new file mode 100644 index 0000000..26be235 --- /dev/null +++ b/.ackrc @@ -0,0 +1 @@ +--ignore-dir=vendor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9933946 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/monohook +/release +/vendor diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1eba45e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: go + +go: + - "1.7" + - "1.8" + - "1.9" + - "1.10" + - "1.11" + - "1.x" + +os: ubuntu +dist: xenial + +before_install: + - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + +install: + - make install diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..a8083c6 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,61 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + digest = "1:c6872fbc5399e91e4854bd32e842998722ed8419a82aa8a6aaeaad9068fcd0af" + name = "github.com/buildkite/interpolate" + packages = ["."] + pruneopts = "" + revision = "973457fa2b4c81e6b755ac41509d41f18027ca77" + +[[projects]] + digest = "1:e988ed0ca0d81f4d28772760c02ee95084961311291bdfefc1b04617c178b722" + name = "github.com/fatih/color" + packages = ["."] + pruneopts = "" + revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" + version = "v1.7.0" + +[[projects]] + digest = "1:9ea83adf8e96d6304f394d40436f2eb44c1dc3250d223b74088cc253a6cd0a1c" + name = "github.com/mattn/go-colorable" + packages = ["."] + pruneopts = "" + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + digest = "1:d0600e4cf07697303f37130791b2ce4577367931416bea8ec4f601bde3f7c5bf" + name = "github.com/mattn/go-isatty" + packages = ["."] + pruneopts = "" + revision = "c2a7a6ca930a4cd0bc33a3f298eb71960732a3a7" + version = "v0.0.7" + +[[projects]] + digest = "1:cbaf13cdbfef0e4734ed8a7504f57fe893d471d62a35b982bf6fb3f036449a66" + name = "github.com/spf13/pflag" + packages = ["."] + pruneopts = "" + revision = "298182f68c66c05229eb03ac171abe6e309ee79a" + version = "v1.0.3" + +[[projects]] + branch = "master" + digest = "1:55c52474bb389797ed66db92966e2b9ddc98a25d9d05c8aa55787fe03d4d4084" + name = "golang.org/x/sys" + packages = ["unix"] + pruneopts = "" + revision = "4b34438f7a67ee5f45cc6132e2bad873a20324e9" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = [ + "github.com/buildkite/interpolate", + "github.com/fatih/color", + "github.com/spf13/pflag", + ] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..328d9fb --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,32 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +[[constraint]] + name = "github.com/fatih/color" + version = "1.5.0" + +[[constraint]] + name = "github.com/spf13/pflag" + version = "1.0.0" + +[[constraint]] + branch = "master" + name = "github.com/buildkite/interpolate" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..dffd084 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2019 Simon Oulevay (Alpha Hydrae) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb8da1b --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +SHELL := /usr/bin/env bash + +test: + go test -count=1 -v ./... + +install: + dep ensure + +build: + ./scripts/build.sh + +doctoc: + command -v doctoc &>/dev/null && doctoc README.md || { >&2 echo "Error: install doctoc with \`npm install -g doctoc\`"; exit 1; } diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa2a228 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# monohook + +Run a single HTTP webhook endpoint that executes a command (e.g. a deployment script). + +``` +$> monohook --authorization letmein --concurrency 1 --port 3000 -- deploy.sh +``` + + + + + + +[![version](https://img.shields.io/badge/Version-v1.0.0-blue.svg)](https://github.com/AlphaHydrae/monohook/releases/tag/v1.0.0) +[![build status](https://travis-ci.org/AlphaHydrae/monohook.svg?branch=master)](https://travis-ci.org/AlphaHydrae/monohook) +[![license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt) + + + +## Installation + +* **Linux** + + ``` + wget -O /usr/local/bin/monohook \ + https://github.com/AlphaHydrae/monohook/releases/download/v2.2.0/monohook_linux_amd64 && \ + chmod +x /usr/local/bin/monohook + ``` +* **Linux (arm64)** + + ``` + wget -O /usr/local/bin/monohook \ + https://github.com/AlphaHydrae/monohook/releases/download/v2.2.0/monohook_linux_arm64 && \ + chmod +x /usr/local/bin/monohook + ``` +* **macOS** + + ``` + wget -O /usr/local/bin/monohook \ + https://github.com/AlphaHydrae/monohook/releases/download/v2.2.0/monohook_darwin_amd64 && \ + chmod +x /usr/local/bin/monohook + ``` +* **Windows** + + ``` + wget -O /usr/local/bin/monohook \ + https://github.com/AlphaHydrae/monohook/releases/download/v2.2.0/monohook_windows_amd64 && \ + chmod +x /usr/local/bin/monohook + ``` + + + +## Usage + +``` +monohook runs a single HTTP webhook endpoint that executes a command. + +Usage: + monohook [OPTION...] [--] [EXEC...] + +Options: + -a, --authorization string Bearer token that must be sent in the Authorization header to authenticate + -b, --buffer string Maximum number of requests to queue before refusing subsequent ones until the queue is freed (zero for infinite) (default "10") + -c, --concurrency string Maximum number of times the command should be executed in parallel (zero for infinite concurrency) (default "1") + -C, --cwd string Working directory in which to run the command + -p, --port string Port on which to listen to (default "5000") + -q, --quiet Do not print anything (default false) + +Examples: + Update a file when the hook is triggered: + monohook -- touch hooked.txt + Deploy an application when the hook is triggered: + monohook -a letmein -- deploy-stuff.sh +``` + + + +## Exit codes + +**monohook** may exit with the following status codes: + +Code | Description +:--- | :--- +`1` | Invalid arguments were given. +`2` | An unrecoverable error occurred while trying to interpolate environment variables into options or command arguments. +`3` | The command to execute (provided after `--`) could not be found in the `$PATH`. diff --git a/monohook.go b/monohook.go new file mode 100644 index 0000000..70a141b --- /dev/null +++ b/monohook.go @@ -0,0 +1,228 @@ +// The monohook command runs a single HTTP webhook endpoint that executes a command. +package main + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "sync" + "time" + + "github.com/buildkite/interpolate" + "github.com/fatih/color" + flag "github.com/spf13/pflag" +) + +const usageHeader = `%s runs a single HTTP webhook endpoint that executes a command. + +Usage: + %s [OPTION...] [--] [EXEC...] + +Options: +` + +const usageFooter = ` +Examples: + Update a file when the hook is triggered: + monohook -- touch hooked.txt + Deploy an application when the hook is triggered: + monohook -a letmein -- deploy-stuff.sh +` + +type commandOptions struct { + command string + args []string + cwd string +} + +func main() { + + var authString string + var bufferString string + var concurrencyString string + var cwdString string + var quiet bool + var portString string + + flag.CommandLine.SetOutput(os.Stdout) + + flag.StringVarP(&authString, "authorization", "a", "", "Bearer token that must be sent in the Authorization header to authenticate") + flag.StringVarP(&bufferString, "buffer", "b", "10", "Maximum number of requests to queue before refusing subsequent ones until the queue is freed (zero for infinite)") + flag.StringVarP(&concurrencyString, "concurrency", "c", "1", "Maximum number of times the command should be executed in parallel (zero for infinite concurrency)") + flag.StringVarP(&cwdString, "cwd", "C", "", "Working directory in which to run the command") + flag.BoolVarP(&quiet, "quiet", "q", false, "Do not print anything (default false)") + flag.StringVarP(&portString, "port", "p", "5000", "Port on which to listen to") + + flag.Usage = func() { + fmt.Printf(usageHeader, os.Args[0], os.Args[0]) + flag.PrintDefaults() + fmt.Print(usageFooter) + } + + flag.Parse() + + auth := parseStringOption(authString, "authorization", quiet) + buffer := parseUint64Option(bufferString, "buffer", quiet) + concurrency := parseUint64Option(concurrencyString, "concurrency", quiet) + cwd := parseStringOption(cwdString, "cwd", quiet) + port := parseUint64Option(portString, "port", quiet) + + if port > 65535 { + fail(1, quiet, "port number must be smaller than or equal to 65535") + } + + terminator := -1 + for i := 0; i < len(os.Args); i++ { + if os.Args[i] == "--" { + terminator = i + break + } + } + + var extra []string + var execCommand string + var execArgs []string + if terminator >= 0 && terminator < len(os.Args)-1 { + extra = flag.Args()[0 : len(flag.Args())-(len(os.Args)-terminator-1)] + + var err error + execCommand, err = exec.LookPath(parseStringOption(os.Args[terminator+1], "command", quiet)) + if err != nil { + fail(3, quiet, "could not find command \"%s\"", os.Args[terminator+1]) + } + + execArgs = os.Args[terminator+2 : len(os.Args)] + + for k := range execArgs { + execArgs[k] = parseStringOption(execArgs[k], "command argument "+strconv.FormatInt(int64(k), 10), quiet) + } + } else { + extra = flag.Args() + } + + if len(extra) != 0 { + fail(1, quiet, "no argument expected before the terminator") + } else if execCommand == "" { + fail(1, quiet, "no command to execute was provided") + } + + opts := &commandOptions{} + opts.command = execCommand + opts.args = execArgs + opts.cwd = cwd + + execCh := make(chan *commandOptions, buffer) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + + if auth != "" { + // Refuse request if unauthorized. + header := r.Header.Get("Authorization") + if header == "" || header[0:7] != "Bearer " || header[7:] != auth { + w.WriteHeader(403) + return + } + } + + // Refuse extra requests if buffer is full. + select { + case execCh <- opts: + w.WriteHeader(202) + default: + w.WriteHeader(429) + } + }) + + s := &http.Server{ + Addr: ":" + strconv.FormatUint(port, 10), + Handler: nil, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + go worker(concurrency, execCh) + + s.ListenAndServe() +} + +func interpolateValue(value string) (string, error) { + return interpolate.Interpolate(interpolate.NewSliceEnv(os.Environ()), value) +} + +func parseStringOption(value string, name string, quiet bool) string { + + interpolated, err := interpolateValue(value) + if err != nil { + fail(2, quiet, "%s could not be interpolated", name) + } + + return interpolated +} + +func parseUint64Option(value string, name string, quiet bool) uint64 { + + interpolated, err := interpolate.Interpolate(interpolate.NewSliceEnv(os.Environ()), value) + if err != nil { + fail(2, quiet, "the \"%s\" option could not be interpolated", name) + } + + parsed, err := strconv.ParseUint(interpolated, 10, 64) + if err != nil { + fail(1, quiet, "the \"%s\" option must be an unsigned 64-bit integer", name) + } + + return parsed +} + +func worker(concurrency uint64, execChannel chan *commandOptions) { + fmt.Fprintf(os.Stderr, "Execution worker started\n") + + n := uint64(0) + wait := &sync.WaitGroup{} + + for job := range execChannel { + + if concurrency >= 1 { + n++ + wait.Add(1) + } + + go execCommand(job, wait) + + // Wait for queue to clear if concurrency is limited. + if concurrency >= 1 && n >= concurrency { + wait.Wait() + n -= concurrency + } + } +} + +func execCommand(opts *commandOptions, waitGroup *sync.WaitGroup) { + + fmt.Fprintf(os.Stderr, "Executing %s\n", opts.command) + + cmd := exec.Command(opts.command, opts.args...) + cmd.Env = os.Environ() + cmd.Stderr = os.Stdout + cmd.Stdout = os.Stdout + + err := cmd.Run() + if err != nil { + fmt.Fprintf(os.Stderr, color.RedString("Command %s error: %s\n"), opts.command, err) + } else { + fmt.Fprintf(os.Stderr, color.GreenString("Successfully executed %s\n"), opts.command) + } + + waitGroup.Done() +} + +func fail(code int, quiet bool, format string, values ...interface{}) { + if !quiet { + fmt.Fprintf(os.Stderr, color.RedString("Error: "+format+"\n"), values...) + } + + os.Exit(code) +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..beb51d9 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -e + +bold=$(tput bold) +normal=$(tput sgr0) +versions="darwin_amd64 linux_amd64 linux_arm64 windows_amd64" + +test -z "$RELEASE" && RELEASE="$(git describe --tags || echo -n)" + +if test -z "$RELEASE"; then + >&2 echo No Git tag found + exit 1 +fi + +rm -fr release/${RELEASE} + +printf "\n${bold}○ Building binaries...${normal}\n" + +for version in $versions; do + os="$(echo $version | cut -d _ -f 1)" + arch="$(echo $version | cut -d _ -f 2)" + env GOOS=$os GOARCH=$arch go build -ldflags="-s -w" -o release/${RELEASE}/monohook_${os}_${arch} & +done + +wait + +for version in $versions; do + file="release/${RELEASE}/monohook_${version}" + printf "\n${bold}○ Compressing ${file}...${normal}\n\n" + upx --ultra-brute "$file" +done + +printf "\n${bold}○ Calculating digests...${normal}\n\n" +dgstore 'release/**/*' + +echo