Iniital version

This commit is contained in:
Knut Ahlers 2023-03-25 15:49:30 +01:00
commit 2535d02951
Signed by: luzifer
GPG key ID: D91C3E91E4CAD6F5
7 changed files with 436 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
mysqlapi

29
Dockerfile Normal file
View file

@ -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 <knut@ahlers.me>"
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:

69
README.md Normal file
View file

@ -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&...&paramN=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
]
```

19
go.mod Normal file
View file

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

32
go.sum Normal file
View file

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

216
http.go Normal file
View file

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

70
main.go Normal file
View file

@ -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&...&paramN=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")
}
}