From 343e1fad53ac2aa7c5e606d66f3633bcdb136827 Mon Sep 17 00:00:00 2001
From: Tony Blyler <me@tonyblyler.com>
Date: Thu, 21 Jan 2021 23:21:40 -0500
Subject: [PATCH] Initial commit providing the basics

---
 README.md           |  42 ++++++++++++++-
 config/config.go    |  28 ++++++++++
 config/json_file.go |  37 +++++++++++++
 example_config.json |  41 ++++++++++++++
 go.mod              |   3 ++
 main.go             | 128 ++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 278 insertions(+), 1 deletion(-)
 create mode 100644 config/config.go
 create mode 100644 config/json_file.go
 create mode 100644 example_config.json
 create mode 100644 go.mod
 create mode 100644 main.go

diff --git a/README.md b/README.md
index 12b15f9..17f8da8 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,41 @@
-# httpwrap
\ No newline at end of file
+# httpwrap
+
+A simple HTTP server to wrap around shell executions.
+
+# Configuration
+
+See [the example config](./example_config.json) and the [config.Config structure](./config/config.go)
+
+# Install
+
+## Prerequisites
+
+* [Go](https://golang.org) version >= 1.16
+
+* `export PATH="$(go env GOPATH)/bin:$PATH"`
+
+## Steps
+
+1. `go install github.com/tblyler/httpwrap@latest`
+
+2. Set `CONFIG_FILE_PATH` environment variable to the path for your config file. [See reference example config](./example_config.json)
+
+3. Execute `httpwrap`
+
+# TODO
+
+- [ ] Allow execution of httpwrap to wrap around another command execution like... `httpwrap /bin/my-non-http-program`. Forwarding `kill` signals, `STDOUT`, and `STDERR` appropriately.
+
+- [ ] Add proper logging & error handling
+
+- [ ] Add support for more config formats
+
+- [ ] Add Makefile
+
+- [ ] Add CI pipeline
+
+- [ ] Make artifacts available for download in "Releases"
+
+- [ ] Add unit tests
+
+- [X] Stop judging me, I wrote this for my Raspberry Pi
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..1d65e42
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,28 @@
+package config
+
+import (
+	"context"
+)
+
+// Endpoint to listen for requests and what to execute
+type Endpoint struct {
+	Command                string   `json:"command"`
+	Arguments              []string `json:"arguments"`
+	HTTPMethod             string   `json:"http_method"`
+	AllowExternalArguments bool     `json:"allow_external_arguments"`
+	AllowStdin             bool     `json:"allow_stdin"`
+	DiscardStderr          bool     `json:"discard_stderr"`
+	DiscardStdout          bool     `json:"discard_stdout"`
+}
+
+// Config information for httpwrap
+type Config struct {
+	Endpoints     map[string]*Endpoint `json:"endpoints"`
+	ListenAddress string               `json:"listen_address"`
+	ListenPort    uint16               `json:"listen_port"`
+}
+
+// Source that can get a Config instance
+type Source interface {
+	Config(context.Context) (*Config, error)
+}
diff --git a/config/json_file.go b/config/json_file.go
new file mode 100644
index 0000000..1c86378
--- /dev/null
+++ b/config/json_file.go
@@ -0,0 +1,37 @@
+package config
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+)
+
+// JSONFileSource gets the config from a JSON file
+type JSONFileSource struct {
+	filePath string
+}
+
+// NewJSONFileSource creates a new JSONFileSource instance for the given path
+func NewJSONFileSource(filePath string) *JSONFileSource {
+	return &JSONFileSource{
+		filePath: filePath,
+	}
+}
+
+// Config gets the config from this file source
+func (jfs *JSONFileSource) Config(ctx context.Context) (*Config, error) {
+	config := &Config{}
+
+	data, err := os.ReadFile(jfs.filePath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read JSON config file at %s: %w", jfs.filePath, err)
+	}
+
+	err = json.Unmarshal(data, config)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decode JSON config file at %s: %w", jfs.filePath, err)
+	}
+
+	return config, nil
+}
diff --git a/example_config.json b/example_config.json
new file mode 100644
index 0000000..b01240d
--- /dev/null
+++ b/example_config.json
@@ -0,0 +1,41 @@
+{
+    "listen_address": "localhost",
+    "listen_port": 8181,
+    "endpoints": {
+        "/harambe": {
+            "command": "echo",
+            "arguments": ["harambe", "lives"],
+            "http_method": "GET",
+            "allow_external_arguments": false,
+            "allow_stdin": false,
+            "discard_stderr": false,
+            "discard_stdout": false
+        },
+        "/good_exit": {
+            "command": "true"
+        },
+        "/bad_exit": {
+            "command": "false"
+        },
+        "/date": {
+            "command": "date"
+        },
+        "/echo": {
+            "command": "cat",
+            "http_method": "POST",
+            "allow_stdin": true
+        },
+        "/echo_args": {
+            "command": "echo",
+            "allow_external_arguments": true
+        },
+        "/stderr": {
+            "command": "/bin/sh",
+            "arguments": ["-c", ">&2 echo 'this is in STDERR'"]
+        },
+        "/stderr_and_stdout": {
+            "command": "/bin/sh",
+            "arguments": ["-c", "echo 'STDOUT'; >&2 echo 'STDERR'"]
+        }
+    }
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1714aa0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/tblyler/httpwrap
+
+go 1.16
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..c38b4f2
--- /dev/null
+++ b/main.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+	"os/exec"
+	"os/signal"
+	"strings"
+	"time"
+
+	"github.com/tblyler/httpwrap/config"
+)
+
+// CommandResponse to be returned from an endpoint
+type CommandResponse struct {
+	StartTime time.Time `json:"start_time"`
+	EndTime   time.Time `json:"end_time"`
+	StdOut    string    `json:"stdout,omitempty"`
+	StdErr    string    `json:"stderr,omitempty"`
+	ExitCode  int       `json:"exit_code,omitempty"`
+	Error     string    `json:"error,omitempty"`
+}
+
+func main() {
+	configFilePath := os.Getenv("CONFIG_FILE_PATH")
+	if configFilePath == "" {
+		fmt.Fprintln(os.Stderr, "CONFIG_FILE_PATH environment variable must be set")
+		os.Exit(1)
+	}
+
+	config, err := config.NewJSONFileSource(configFilePath).Config(context.Background())
+	if err != nil {
+		fmt.Fprintln(os.Stderr, err.Error())
+		os.Exit(2)
+	}
+
+	for route, endpoint := range config.Endpoints {
+		endpoint := endpoint
+		http.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
+			if endpoint.HTTPMethod == "" {
+				endpoint.HTTPMethod = http.MethodGet
+			}
+
+			if strings.ToUpper(endpoint.HTTPMethod) != strings.ToUpper(r.Method) {
+				w.WriteHeader(http.StatusBadRequest)
+				fmt.Fprintln(w, "unconfigured HTTP method:", r.Method)
+				return
+			}
+
+			args := endpoint.Arguments
+			if endpoint.AllowExternalArguments {
+				externalArgs, _ := r.URL.Query()["args"]
+				args = append(args, externalArgs...)
+			}
+
+			command := exec.CommandContext(
+				r.Context(),
+				endpoint.Command,
+				args...,
+			)
+
+			if endpoint.AllowStdin {
+				command.Stdin = r.Body
+			}
+
+			stdErr := bytes.NewBuffer(nil)
+			if !endpoint.DiscardStderr {
+				command.Stderr = stdErr
+			}
+
+			stdOut := bytes.NewBuffer(nil)
+			if !endpoint.DiscardStdout {
+				command.Stdout = stdOut
+			}
+
+			response := &CommandResponse{
+				StartTime: time.Now(),
+			}
+
+			err := command.Start()
+			if err != nil {
+				response.EndTime = time.Now()
+				response.Error = fmt.Sprintf("failed to start command: %v", err)
+				w.WriteHeader(http.StatusInternalServerError)
+				json.NewEncoder(w).Encode(response)
+				return
+			}
+
+			command.Wait()
+
+			response.EndTime = time.Now()
+			response.ExitCode = command.ProcessState.ExitCode()
+			response.StdErr = stdErr.String()
+			response.StdOut = stdOut.String()
+
+			json.NewEncoder(w).Encode(response)
+		})
+	}
+
+	httpServer := &http.Server{
+		Addr: fmt.Sprintf("%s:%d", config.ListenAddress, config.ListenPort),
+	}
+
+	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
+	go func() {
+		<-ctx.Done()
+		fmt.Println("got interrupt signal, waiting up to 5 seconds to gracefully shut down")
+
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(5))
+		defer cancel()
+
+		httpServer.Shutdown(ctx)
+	}()
+
+	fmt.Println("listening on:", httpServer.Addr)
+	err = httpServer.ListenAndServe()
+	if err != nil && !errors.Is(err, http.ErrServerClosed) {
+		fmt.Println("got error from HTTP server", err.Error())
+		os.Exit(3)
+	}
+
+	fmt.Println("successfully shut down")
+}