Iniital version
This commit is contained in:
commit
2535d02951
7 changed files with 436 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
mysqlapi
|
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
69
README.md
Normal 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&...¶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
|
||||||
|
]
|
||||||
|
```
|
19
go.mod
Normal file
19
go.mod
Normal 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
32
go.sum
Normal 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
216
http.go
Normal 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
70
main.go
Normal 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&...¶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")
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue