Initial commit providing the basics
This commit is contained in:
parent
7112efc5f5
commit
343e1fad53
6 changed files with 278 additions and 1 deletions
42
README.md
42
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