From 2535d029515b7f0869852837400a198a416f8fba Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sat, 25 Mar 2023 15:49:30 +0100 Subject: [PATCH] Iniital version --- .gitignore | 1 + Dockerfile | 29 +++++++ README.md | 69 +++++++++++++++++ go.mod | 19 +++++ go.sum | 32 ++++++++ http.go | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 70 +++++++++++++++++ 7 files changed, 436 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd5e647 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +mysqlapi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37a8dbe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM golang:alpine as builder + +COPY . /go/src/github.com/Luzifer/mysqlapi +WORKDIR /go/src/github.com/Luzifer/mysqlapi + +RUN set -ex \ + && apk add --update git \ + && go install \ + -ldflags "-X main.version=$(git describe --tags --always || echo dev)" \ + -mod=readonly \ + -modcacherw \ + -trimpath + +FROM alpine:latest + +LABEL maintainer "Knut Ahlers " + +RUN set -ex \ + && apk --no-cache add \ + ca-certificates + +COPY --from=builder /go/bin/mysqlapi /usr/local/bin/mysqlapi + +EXPOSE 3000 + +ENTRYPOINT ["/usr/local/bin/mysqlapi"] +CMD ["--"] + +# vim: set ft=Dockerfile: diff --git a/README.md b/README.md new file mode 100644 index 0000000..53997f7 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Luzifer / mysqlapi + +This repo contains a simple-ish web-application to translate HTTP POST requests into MySQL queries. + +## Why?!? + +I had the requirement to do SQL queries from fairly simple scripts without the possibility to add a MySQL client. As HTTP calls are possible in nearly every environement the idea was to have an API to execute arbitrary SQL statements over a JSON POST-API. + +## Security + +**⚠⚠⚠ NEVER EVER LEAVE THIS OPEN TO THE INTERNET! ⚠⚠⚠** + +Having stated that as clearly as possible: This API does not limit the type of queries being executed. The only thing saving you might be the permissions of the user you configured in the DSN given to the tool. If you gave it global admin permissions, well - you've just handed over your database server. + +In general make sure you understood what is possible using this and limit access to an absolute minimum. Your data got lost / leaked? I did warn you. + +## How to use? + +``` +POST /{database} +Content-Type: application/json + +[...] +``` + +```console +# mysqlapi --help +Usage of mysqlapi: + --dsn string MySQL DSN to connect to: [username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN] + --listen string Port/IP to listen on (default ":3000") + --log-level string Log level (debug, info, warn, error, fatal) (default "info") + --version Prints current version and exits + +# mysqlapi \ + --dsn "limiteduser:verysecretpass@tcp(mydatabase.cluster.local:3306)/?charset=utf8mb4&parseTime=True&loc=Local" \ + --listen 127.0.0.1:7895 +INFO[0000] mysqlapi started addr="127.0.0.1:7895" version=dev + +# curl -s --data-binary @select.json localhost:7895/mysqlapi_test | jq . +``` + +**Request format** + +```json +[ + ["SELECT * FROM testtable"], + ["INSERT INTO testtable (name, age, birthday) VALUES (?, ?, ?)", "Karl", 45, "1999-02-05T02:00:00"], + ["SELECT * FROM testtable WHERE name = ?", "Karl"], + ["DELETE FROM testtable WHERE name = ?", "Karl"] +] +``` + +**Response format** + +```json +[ + null, + null, + [ + { + "age": 45, + "birthday": "1999-02-05T02:00:00+01:00", + "id": 1, + "name": "Karl" + } + ], + null +] +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f1f64c7 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/Luzifer/mysqlapi + +go 1.20 + +require ( + github.com/Luzifer/rconfig/v2 v2.4.0 + github.com/go-sql-driver/mysql v1.7.0 + github.com/gofrs/uuid v4.4.0+incompatible + github.com/gorilla/mux v1.8.0 + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.9.0 +) + +require ( + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1595819 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o= +github.com/Luzifer/rconfig/v2 v2.4.0/go.mod h1:hWF3ZVSusbYlg5bEvCwalEyUSY+0JPJWUiIu7rBmav8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/validator.v2 v2.0.0-20210331031555-b37d688a7fb0 h1:EFLtLCwd8tGN+r/ePz3cvRtdsfYNhDEdt/vp6qsT+0A= +gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http.go b/http.go new file mode 100644 index 0000000..bfe57fd --- /dev/null +++ b/http.go @@ -0,0 +1,216 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "reflect" + + "github.com/go-sql-driver/mysql" + "github.com/gofrs/uuid" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +type ( + request [][]any + response [][]map[string]any +) + +/* +=== REQ +[ + ["SELECT * FROM tablename WHERE name = ?", "foobar"] +] + +=== RESP +[ + [{"name": "foobar", "age": 25}, {"name": "barfoo", "age": 56}] +] +*/ + +func executeQuery(db *sql.DB, query []any, resp *response) error { + if len(query) == 0 { + return errors.New("no query given") + } + + qs, ok := query[0].(string) + if !ok { + return errors.Errorf("expected query as string in first argument, got %T", query[0]) + } + + rows, err := db.Query(qs, query[1:]...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + var respForQuery []map[string]any + + colTypes, err := rows.ColumnTypes() + if err != nil { + return errors.Wrap(err, "getting column types") + } + + for rows.Next() { + var ( + scanNames []string + scanSet []any + ) + + for _, col := range colTypes { + scanNames = append(scanNames, col.Name()) + scanSet = append(scanSet, reflect.New(col.ScanType()).Interface()) + } + + if err = rows.Err(); err != nil { + return errors.Wrap(err, "iterating rows") + } + + if err = rows.Scan(scanSet...); err != nil { + return errors.Wrap(err, "scanning row") + } + + respForQuery = append(respForQuery, scanSetToObject(scanNames, scanSet)) + } + + if err = rows.Err(); err != nil { + return errors.Wrap(err, "iterating rows (final)") + } + + *resp = append(*resp, respForQuery) + return nil +} + +func handleRequest(w http.ResponseWriter, r *http.Request) { + var ( + connID = uuid.Must(uuid.NewV4()).String() + database = mux.Vars(r)["database"] + logger = logrus.WithFields(logrus.Fields{ + "conn": connID, + "db": database, + }) + + connError = func(err error, reason string, code int) { + logger.WithError(err).Error(reason) + http.Error(w, fmt.Sprintf("an error occurred: %s", connID), http.StatusInternalServerError) + } + ) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Conn-ID", connID) + + connInfo, err := mysql.ParseDSN(cfg.DSN) + if err != nil { + connError(err, "parsing DSN", http.StatusInternalServerError) + return + } + connInfo.DBName = database + + db, err := sql.Open("mysql", connInfo.FormatDSN()) + if err != nil { + connError(err, "opening db connection", http.StatusInternalServerError) + return + } + defer func() { + if err := db.Close(); err != nil { + logger.WithError(err).Error("closing db connection") + } + }() + + var ( + req request + resp response + ) + + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + connError(err, "parsing request", http.StatusBadRequest) + return + } + + for i, query := range req { + if err = executeQuery(db, query, &resp); err != nil { + connError(err, fmt.Sprintf("executing query %d", i), http.StatusInternalServerError) + return + } + } + + if err = json.NewEncoder(w).Encode(resp); err != nil { + connError(err, "encoding response", http.StatusInternalServerError) + return + } +} + +//nolint:gocognit,gocyclo // contains simple type conversions +func scanSetToObject(scanNames []string, scanSet []any) map[string]any { + row := make(map[string]any) + for idx, name := range scanNames { + // Some types are not very JSON friendly, lets make them + switch tv := scanSet[idx].(type) { + case *sql.NullBool: + if tv.Valid { + scanSet[idx] = tv.Bool + } else { + scanSet[idx] = nil + } + + case *sql.NullByte: + if tv.Valid { + scanSet[idx] = tv.Byte + } else { + scanSet[idx] = nil + } + + case *sql.NullFloat64: + if tv.Valid { + scanSet[idx] = tv.Float64 + } else { + scanSet[idx] = nil + } + + case *sql.NullInt16: + if tv.Valid { + scanSet[idx] = tv.Int16 + } else { + scanSet[idx] = nil + } + + case *sql.NullInt32: + if tv.Valid { + scanSet[idx] = tv.Int32 + } else { + scanSet[idx] = nil + } + + case *sql.NullInt64: + if tv.Valid { + scanSet[idx] = tv.Int64 + } else { + scanSet[idx] = nil + } + + case *sql.NullString: + if tv.Valid { + scanSet[idx] = tv.String + } else { + scanSet[idx] = nil + } + + case *sql.NullTime: + if tv.Valid { + scanSet[idx] = tv.Time + } else { + scanSet[idx] = nil + } + + case *sql.RawBytes: + scanSet[idx] = string(*tv) + } + + row[name] = scanSet[idx] + } + + return row +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..71fbf1c --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/Luzifer/rconfig/v2" +) + +var ( + cfg = struct { + Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"` + LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` + DSN string `flag:"dsn" default:"" description:"MySQL DSN to connect to: [username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN]"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + }{} + + version = "dev" +) + +func initApp() error { + rconfig.AutoEnv(true) + if err := rconfig.ParseAndValidate(&cfg); err != nil { + return errors.Wrap(err, "parsing cli options") + } + + l, err := logrus.ParseLevel(cfg.LogLevel) + if err != nil { + return errors.Wrap(err, "parsing log-level") + } + logrus.SetLevel(l) + + return nil +} + +func main() { + var err error + if err = initApp(); err != nil { + logrus.WithError(err).Fatal("initializing app") + } + + if cfg.VersionAndExit { + fmt.Printf("mysqlapi %s\n", version) + os.Exit(0) + } + + router := mux.NewRouter() + router.HandleFunc("/{database}", handleRequest).Methods(http.MethodPost) + + server := &http.Server{ + Addr: cfg.Listen, + Handler: router, + ReadHeaderTimeout: time.Second, + } + + logrus.WithFields(logrus.Fields{ + "addr": cfg.Listen, + "version": version, + }).Info("mysqlapi started") + + if err = server.ListenAndServe(); err != nil { + logrus.WithError(err).Fatal("listening for HTTP") + } +}