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") +}