Initial commit providing the basics

This commit is contained in:
Tony Blyler 2021-01-21 23:21:40 -05:00
parent 7112efc5f5
commit 343e1fad53
6 changed files with 278 additions and 1 deletions

View file

@ -1 +1,41 @@
# httpwrap
# 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

28
config/config.go Normal file
View file

@ -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)
}

37
config/json_file.go Normal file
View file

@ -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
}

41
example_config.json Normal file
View file

@ -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'"]
}
}
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/tblyler/httpwrap
go 1.16

128
main.go Normal file
View file

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