diff --git a/README.md b/README.md index cf34cd2..33c02d6 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ It currently does the following things: - **Event log**: Services inside a TDX instance can record events they want exposed to the operator used to record and query events. Useful to record service startup/shutdown, errors, progress updates, hashes, etc. +- **Actions**: Ability to execute shell commands via API Future features: - Operator can set a password for http-basic-auth (persisted, for all future requests) - Operator-provided configuration (i.e. config values, secrets, etc.) -- Restart of services / execution of scripts --- @@ -29,7 +29,20 @@ $ echo "hello world" > pipe.fifo $ curl localhost:3535/api/v1/new_event?message=this+is+a+test # Query events (plain text or JSON is supported) -$ curl -s localhost:3535/api/v1/events?format=text +$ curl localhost:3535/api/v1/events?format=text 2024-10-23T12:04:01Z hello world 2024-10-23T12:04:07Z this is a test ``` + + ## Actions + + Actions are shell commands that can be executed via API. The commands are defined in the config file, + see [systemapi-config.toml](./systemapi-config.toml) for examples. + +```bash +# Start the server +$ go run cmd/system-api/main.go --config systemapi-config.toml + +# Execute the example action +$ curl -v localhost:3535/api/v1/actions/echo_test +``` \ No newline at end of file diff --git a/cmd/system-api/main.go b/cmd/system-api/main.go index 4e15955..b05e8fa 100644 --- a/cmd/system-api/main.go +++ b/cmd/system-api/main.go @@ -34,6 +34,11 @@ var flags []cli.Flag = []cli.Flag{ Value: true, Usage: "log debug messages", }, + &cli.StringFlag{ + Name: "config", + Value: "", + Usage: "config file", + }, } func main() { @@ -50,7 +55,7 @@ func main() { } } -func runCli(cCtx *cli.Context) error { +func runCli(cCtx *cli.Context) (err error) { listenAddr := cCtx.String("listen-addr") pipeFile := cCtx.String("pipe-file") logJSON := cCtx.Bool("log-json") @@ -59,6 +64,7 @@ func runCli(cCtx *cli.Context) error { logTags := map[string]string{ "version": common.Version, } + configFile := cCtx.String("config") log := common.SetupLogger(&common.LoggingOpts{ JSON: logJSON, @@ -68,12 +74,24 @@ func runCli(cCtx *cli.Context) error { Tags: logTags, }) + var config *systemapi.SystemAPIConfig + if configFile != "" { + config, err = systemapi.LoadConfigFromFile(configFile) + if err != nil { + log.Error("Error loading config", "err", err) + return err + } + log.Info("Loaded config", "config-file", config) + } + // Setup and start the server (in the background) - server, err := systemapi.NewServer(&systemapi.HTTPServerConfig{ + cfg := &systemapi.HTTPServerConfig{ ListenAddr: listenAddr, Log: log, PipeFilename: pipeFile, - }) + Config: config, + } + server, err := systemapi.NewServer(cfg) if err != nil { return err } diff --git a/common/utils.go b/common/utils.go index 4839d81..89beb52 100644 --- a/common/utils.go +++ b/common/utils.go @@ -5,6 +5,8 @@ import ( "strconv" ) +// GetEnvInt returns the value of the environment variable named by key, or defaultValue if the environment variable +// doesn't exist or is not a valid integer func GetEnvInt(key string, defaultValue int) int { if value, ok := os.LookupEnv(key); ok { val, err := strconv.Atoi(value) @@ -14,3 +16,11 @@ func GetEnvInt(key string, defaultValue int) int { } return defaultValue } + +// GetEnv returns the value of the environment variable named by key, or defaultValue if the environment variable doesn't exist +func GetEnv(key, defaultValue string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return defaultValue +} diff --git a/go.mod b/go.mod index 599728d..3ea901f 100644 --- a/go.mod +++ b/go.mod @@ -8,16 +8,21 @@ require ( github.com/ethereum/go-ethereum v1.14.9 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/httplog/v2 v2.1.1 + github.com/pelletier/go-toml/v2 v2.2.3 + github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v2 v2.27.2 ) require ( github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/holiman/uint256 v1.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect golang.org/x/crypto v0.22.0 // indirect golang.org/x/sys v0.25.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bfb97a0..3285a41 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,14 @@ github.com/go-chi/httplog/v2 v2.1.1 h1:ojojiu4PIaoeJ/qAO4GWUxJqvYUTobeo7zmuHQJAx github.com/go-chi/httplog/v2 v2.1.1/go.mod h1:/XXdxicJsp4BA5fapgIC3VuTD+z0Z/VzukoB3VDc1YE= github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs= github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= @@ -28,3 +34,7 @@ golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/systemapi-config.toml b/systemapi-config.toml new file mode 100644 index 0000000..7880d8f --- /dev/null +++ b/systemapi-config.toml @@ -0,0 +1,5 @@ +[actions] +# reboot = "reboot" +# rbuilder_restart = "/etc/init.d/rbuilder restart" +# rbuilder_stop = "/etc/init.d/rbuilder stop" +echo_test = "echo test" diff --git a/systemapi/config.go b/systemapi/config.go new file mode 100644 index 0000000..35aee39 --- /dev/null +++ b/systemapi/config.go @@ -0,0 +1,28 @@ +package systemapi + +import ( + "os" + + toml "github.com/pelletier/go-toml/v2" +) + +type SystemAPIConfig struct { + Actions map[string]string +} + +func LoadConfigFromFile(path string) (*SystemAPIConfig, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return LoadConfig(content) +} + +func LoadConfig(content []byte) (*SystemAPIConfig, error) { + cfg := &SystemAPIConfig{} + err := toml.Unmarshal(content, cfg) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/systemapi/config_test.go b/systemapi/config_test.go new file mode 100644 index 0000000..1b0af60 --- /dev/null +++ b/systemapi/config_test.go @@ -0,0 +1,16 @@ +package systemapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadConfig(t *testing.T) { + path := "../systemapi-config.toml" + cfg, err := LoadConfigFromFile(path) + require.NoError(t, err) + require.NotNil(t, cfg) + require.NotEmpty(t, cfg.Actions) + require.Equal(t, "echo test", cfg.Actions["echo_test"]) +} diff --git a/systemapi/server.go b/systemapi/server.go index 95c7743..a976857 100644 --- a/systemapi/server.go +++ b/systemapi/server.go @@ -3,24 +3,23 @@ package systemapi import ( "bufio" + "bytes" "context" "encoding/json" "errors" "net/http" "os" + "os/exec" "strings" "sync" "syscall" "time" - "github.com/flashbots/system-api/common" chi "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httplog/v2" ) -var MaxEvents = common.GetEnvInt("MAX_EVENTS", 1000) - type HTTPServerConfig struct { ListenAddr string Log *httplog.Logger @@ -31,6 +30,8 @@ type HTTPServerConfig struct { GracefulShutdownDuration time.Duration ReadTimeout time.Duration WriteTimeout time.Duration + + Config *SystemAPIConfig } type Event struct { @@ -86,6 +87,7 @@ func (s *Server) getRouter() http.Handler { mux.Get("/livez", s.handleLivenessCheck) mux.Get("/api/v1/new_event", s.handleNewEvent) mux.Get("/api/v1/events", s.handleGetEvents) + mux.Get("/api/v1/actions/{action}", s.handleAction) if s.cfg.EnablePprof { s.log.Info("pprof API enabled") @@ -191,3 +193,40 @@ func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) { return } } + +func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) { + action := chi.URLParam(r, "action") + s.log.Info("Received action", "action", action) + + if s.cfg.Config == nil { + w.WriteHeader(http.StatusNotImplemented) + return + } + + cmd, ok := s.cfg.Config.Actions[action] + if !ok { + w.WriteHeader(http.StatusNotImplemented) + return + } + + s.log.Info("Executing action", "action", action, "cmd", cmd) + stdout, stderr, err := Shellout(cmd) + if err != nil { + s.log.Error("Failed to execute action", "action", action, "cmd", cmd, "err", err, "stderr", stderr) + w.WriteHeader(http.StatusInternalServerError) + return + } + + s.log.Info("Action executed", "action", action, "cmd", cmd, "stdout", stdout, "stderr", stderr) + w.WriteHeader(http.StatusOK) +} + +func Shellout(command string) (string, string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := exec.Command(ShellToUse, "-c", command) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} diff --git a/systemapi/vars.go b/systemapi/vars.go new file mode 100644 index 0000000..35674b1 --- /dev/null +++ b/systemapi/vars.go @@ -0,0 +1,8 @@ +package systemapi + +import "github.com/flashbots/system-api/common" + +var ( + MaxEvents = common.GetEnvInt("MAX_EVENTS", 1000) + ShellToUse = common.GetEnv("SHELL_TO_USE", "/bin/ash") +)