Initial commit providing the basics
This commit is contained in:
parent
7112efc5f5
commit
343e1fad53
6 changed files with 278 additions and 1 deletions
40
README.md
40
README.md
|
@ -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
28
config/config.go
Normal 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
37
config/json_file.go
Normal 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
41
example_config.json
Normal 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
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/tblyler/httpwrap
|
||||||
|
|
||||||
|
go 1.16
|
128
main.go
Normal file
128
main.go
Normal 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")
|
||||||
|
}
|
Loading…
Reference in a new issue