From 17a4bd6515c5db18a1a129097c159882586f5994 Mon Sep 17 00:00:00 2001 From: Geoff Bourne Date: Mon, 7 May 2018 22:16:01 -0500 Subject: [PATCH] Initial commit --- .gitignore | 4 + Gopkg.lock | 63 ++++++++++++++++ Gopkg.toml | 38 ++++++++++ LICENSE.txt | 21 ++++++ README.md | 30 ++++++++ cmd/mc-router/main.go | 42 +++++++++++ mcproto/types.go | 168 ++++++++++++++++++++++++++++++++++++++++++ server/api_server.go | 17 +++++ server/connector.go | 144 ++++++++++++++++++++++++++++++++++++ server/routes.go | 131 ++++++++++++++++++++++++++++++++ 10 files changed, 658 insertions(+) create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 cmd/mc-router/main.go create mode 100644 mcproto/types.go create mode 100644 server/api_server.go create mode 100644 server/connector.go create mode 100644 server/routes.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b0a980 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ + +/.idea/ +/*.iml \ No newline at end of file diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..b448c0e --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,63 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/alecthomas/kingpin" + packages = ["."] + revision = "947dcec5ba9c011838740e680966fd7087a71d0d" + version = "v2.2.6" + +[[projects]] + branch = "master" + name = "github.com/alecthomas/template" + packages = [ + ".", + "parse" + ] + revision = "a0175ee3bccc567396460bf5acd36800cb10c49c" + +[[projects]] + branch = "master" + name = "github.com/alecthomas/units" + packages = ["."] + revision = "2efee857e7cfd4f3d0138cc3cbb1b4966962b93a" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "53c1911da2b537f792e7cafcb446b05ffe33b996" + version = "v1.6.1" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "4ec37c66abab2c7e02ae775328b2ff001c3f025a" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "6f686a352de66814cdd080d970febae7767857a3" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "8d17b95931fea7bbf18743367ccc152c392add293fe7945ca9ce941ca1482b4f" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..c5a54ee --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,38 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[prune] + go-tests = true + unused-packages = true + +[[constraint]] + name = "github.com/alecthomas/kingpin" + version = "2.2.6" + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.5" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..dfbee81 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Geoff Bourne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..951f294 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +Routes Minecraft client connections to backend servers based upon the requested server address. + +## Usage + +```text +Flags: + --help Show context-sensitive help (also try --help-long + and --help-man). + --port=25565 The port bound to listen for Minecraft client + connections + --api-binding=API-BINDING The host:port bound for servicing API requests + --mapping=MAPPING ... Mapping of external hostname to internal server + host:port +``` + +## REST API + +* `GET /routes` + Retrieves the currently configured routes +* `POST /routes` + Registers a route given a JSON body structured like: +```json +{ + "serverAddress": "CLIENT REQUESTED SERVER ADDRESS", + "backend": "HOST:PORT" +} +``` + +* `DELETE /routes/{serverAddress}` + Deletes an existing route for the given `serverAddress` \ No newline at end of file diff --git a/cmd/mc-router/main.go b/cmd/mc-router/main.go new file mode 100644 index 0000000..ae2d3bb --- /dev/null +++ b/cmd/mc-router/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "net" + "github.com/itzg/mc-router/server" + "github.com/alecthomas/kingpin" + "strconv" + "github.com/sirupsen/logrus" + "context" + "os" + "os/signal" +) + +var ( + port = kingpin.Flag("port", "The port bound to listen for Minecraft client connections"). + Default("25565").Int() + apiBinding = kingpin.Flag("api-binding", "The host:port bound for servicing API requests"). + String() + mappings = kingpin.Flag("mapping", "Mapping of external hostname to internal server host:port"). + StringMap() +) + +func main() { + kingpin.Parse() + + ctx, cancel := context.WithCancel(context.Background()) + + c := make(chan os.Signal, 1) + signal.Notify(c) + + server.Routes.RegisterAll(*mappings) + + server.Connector.StartAcceptingConnections(ctx, net.JoinHostPort("", strconv.Itoa(*port))) + + if *apiBinding != "" { + server.StartApiServer(*apiBinding) + } + + <-c + logrus.Info("Stopping") + cancel() +} diff --git a/mcproto/types.go b/mcproto/types.go new file mode 100644 index 0000000..9402e1a --- /dev/null +++ b/mcproto/types.go @@ -0,0 +1,168 @@ +package mcproto + +import ( + "errors" + "bytes" + "strings" + "io" +) + +type Frame struct { + Length int + Payload []byte +} + +type Packet struct { + Length int + PacketID int + Data []byte +} + +const PacketIdHandshake = 0x00 +type Handshake struct { + ProtocolVersion int + ServerAddress string + ServerPort uint16 + NextState int +} + +type ByteReader interface { + ReadByte() (byte, error) +} + +func ReadVarInt(reader io.Reader) (int, error) { + b := make([]byte, 1) + var numRead uint = 0 + result := 0 + for numRead <= 5 { + n, err := reader.Read(b) + if err != nil { + return 0, err + } + if n == 0 { + continue + } + value := b[0] & 0x7F + result |= int(value) << (7*numRead) + + numRead++ + + if b[0] & 0x80 == 0 { + return result, nil + } + } + + return 0, errors.New("VarInt is too big") +} + +func ReadString(reader io.Reader) (string, error) { + length, err := ReadVarInt(reader) + if err != nil { + return "", err + } + + b := make([]byte, 1) + var strBuilder strings.Builder + for i := 0; i < length; i++ { + n, err := reader.Read(b) + if err != nil { + return "", err + } + if n == 0 { + continue + } + strBuilder.WriteByte(b[0]) + } + + return strBuilder.String(), nil +} + +func ReadUnsignedShort(reader io.Reader) (uint16, error) { + upper := make([]byte, 1) + _, err := reader.Read(upper) + if err != nil { + return 0, err + } + lower := make([]byte, 1) + _, err = reader.Read(lower) + if err != nil { + return 0, err + } + + return (uint16(upper[0])<<8) | uint16(lower[0]), nil +} + +func ReadFrame(reader io.Reader) (*Frame, error) { + var err error + frame := &Frame{} + + frame.Length, err = ReadVarInt(reader) + if err != nil { + return nil, err + } + + frame.Payload = make([]byte, frame.Length) + total := 0 + for total < frame.Length { + readIntoThis := frame.Payload[total:] + n, err := reader.Read(readIntoThis) + if err != nil { + if err != io.EOF { + return nil, err + } + } + total += n + } + + return frame, nil +} + +func ReadPacket(reader io.Reader) (*Packet, error) { + + frame, err := ReadFrame(reader) + if err != nil { + return nil, err + } + + packet := &Packet{Length:frame.Length} + + remainder := bytes.NewBuffer(frame.Payload) + + packet.PacketID, err = ReadVarInt(remainder) + if err != nil { + return nil, err + } + + packet.Data = remainder.Bytes() + + return packet, nil +} + +func ReadHandshake(data []byte) (*Handshake, error) { + + handshake := &Handshake{} + buffer := bytes.NewBuffer(data) + var err error + + handshake.ProtocolVersion, err = ReadVarInt(buffer) + if err != nil { + return nil, err + } + + handshake.ServerAddress, err = ReadString(buffer) + if err != nil { + return nil, err + } + + handshake.ServerPort, err = ReadUnsignedShort(buffer) + if err != nil { + return nil, err + } + + nextState, err := ReadVarInt(buffer) + if err != nil { + return nil, err + } + handshake.NextState = nextState + return handshake, nil +} diff --git a/server/api_server.go b/server/api_server.go new file mode 100644 index 0000000..d29e0ea --- /dev/null +++ b/server/api_server.go @@ -0,0 +1,17 @@ +package server + +import ( + "net/http" + "github.com/sirupsen/logrus" + "github.com/gorilla/mux" +) + +var apiRoutes = mux.NewRouter() + +func StartApiServer(apiBinding string) { + logrus.WithField("binding", apiBinding).Info("Serving API requests") + go func() { + logrus.WithError( + http.ListenAndServe(apiBinding, apiRoutes)).Error("API server failed") + }() +} diff --git a/server/connector.go b/server/connector.go new file mode 100644 index 0000000..f8130e9 --- /dev/null +++ b/server/connector.go @@ -0,0 +1,144 @@ +package server + +import ( + "net" + "github.com/sirupsen/logrus" + "github.com/itzg/mc-router/mcproto" + "context" + "io" + "bytes" +) + +type IConnector interface { + StartAcceptingConnections(ctx context.Context, listenAddress string) error +} + +var Connector IConnector = &connectorImpl{} + +type connectorImpl struct { +} + +func (c *connectorImpl) StartAcceptingConnections(ctx context.Context, listenAddress string) error { + + ln, err := net.Listen("tcp", listenAddress) + if err != nil { + logrus.WithError(err).Fatal("Unable to start listening") + return err + } + logrus.WithField("listenAddress", listenAddress).Info("Listening for Minecraft client connections") + + go c.acceptConnections(ctx, ln) + + return nil +} + +func (c *connectorImpl) acceptConnections(ctx context.Context, ln net.Listener) { + defer ln.Close() + + for { + select { + case <-ctx.Done(): + return + + default: + conn, err := ln.Accept() + if err != nil { + logrus.WithError(err).Error("Failed to accept connection") + } else { + go c.HandleConnection(ctx, conn) + } + } + } +} + +func (c *connectorImpl) HandleConnection(ctx context.Context, frontendConn net.Conn) { + defer frontendConn.Close() + + clientAddr := frontendConn.RemoteAddr() + logrus.WithFields(logrus.Fields{"clientAddr": clientAddr}).Info("Got connection") + + inspectionBuffer := new(bytes.Buffer) + + inspectionReader := io.TeeReader(frontendConn, inspectionBuffer) + + packet, err := mcproto.ReadPacket(inspectionReader) + if err != nil { + logrus.WithError(err).WithField("clientAddr", clientAddr).Error("Failed to read packet") + return + } + + logrus.WithFields(logrus.Fields{"length": packet.Length, "packetID": packet.PacketID}).Info("Got packet") + + if packet.PacketID == mcproto.PacketIdHandshake { + handshake, err := mcproto.ReadHandshake(packet.Data) + if err != nil { + logrus.WithError(err).WithField("clientAddr", clientAddr).Error("Failed to read handshake") + return + } + + logrus.WithFields(logrus.Fields{ + "protocolVersion": handshake.ProtocolVersion, + "server": handshake.ServerAddress, + "serverPort": handshake.ServerPort, + "nextState": handshake.NextState, + }).Info("Got handshake") + + backendHostPort := Routes.FindBackendForServerAddress(handshake.ServerAddress) + if backendHostPort == "" { + logrus.WithField("serverAddress", handshake.ServerAddress).Warn("Unable to find registered backend") + return + } + + logrus.WithField("backendHostPort", backendHostPort).Info("Connecting to backend") + backendConn, err := net.Dial("tcp", backendHostPort) + if err != nil { + logrus.WithError(err).WithFields(logrus.Fields{ + "serverAddress": handshake.ServerAddress, + "backend": backendHostPort, + }).Warn("Unable to connect to backend") + return + } + + amount, err := io.Copy(backendConn, inspectionBuffer) + if err != nil { + logrus.WithError(err).Error("Failed to write handshake to backend connection") + return + } + logrus.WithField("amount", amount).Debug("Relayed handshake to backend") + + pumpConnections(ctx, frontendConn, backendConn) + } else { + logrus.WithField("packetID", packet.PacketID).Error("Unexpected packetID, expected handshake") + } +} + +func pumpConnections(ctx context.Context, frontendConn, backendConn net.Conn) { + defer backendConn.Close() + + errors := make(chan error, 2) + + go pumpFrames(backendConn, frontendConn, errors, "backend", "frontend") + go pumpFrames(frontendConn, backendConn, errors, "frontend", "backend") + + for { + select { + case err := <-errors: + if err != io.EOF { + logrus.WithError(err).Error("Error observed on connection relay") + } + + return + + case <-ctx.Done(): + return + } + } +} + +func pumpFrames(incoming io.Reader, outgoing io.Writer, errors chan<- error, from, to string) { + amount, err := io.Copy(outgoing, incoming) + if err != nil { + errors <- err + } + logrus.WithField("amount", amount).Infof("Finished relay %s->%s", from, to) +} diff --git a/server/routes.go b/server/routes.go new file mode 100644 index 0000000..e3081da --- /dev/null +++ b/server/routes.go @@ -0,0 +1,131 @@ +package server + +import ( + "sync" + "net/http" + "encoding/json" + "github.com/sirupsen/logrus" + "github.com/gorilla/mux" +) + +func init() { + apiRoutes.Path("/routes").Methods("GET"). + Headers("Accept", "application/json"). + HandlerFunc(routesListHandler) + apiRoutes.Path("/routes").Methods("POST"). + Headers("Content-Type", "application/json"). + HandlerFunc(routesCreateHandler) + apiRoutes.Path("/routes/{serverAddress}").Methods("DELETE").HandlerFunc(routesDeleteHandler) +} + +func routesListHandler(writer http.ResponseWriter, request *http.Request) { + mappings := Routes.GetMappings() + bytes, err := json.Marshal(mappings) + if err != nil { + logrus.WithError(err).Error("Failed to marshal mappings") + writer.WriteHeader(http.StatusInternalServerError) + return + } + writer.Write(bytes) +} + +func routesDeleteHandler(writer http.ResponseWriter, request *http.Request) { + serverAddress := mux.Vars(request)["serverAddress"] + if serverAddress != "" { + if Routes.DeleteMapping(serverAddress) { + writer.WriteHeader(http.StatusOK) + } else { + writer.WriteHeader(http.StatusNotFound) + } + } +} + +func routesCreateHandler(writer http.ResponseWriter, request *http.Request) { + var definition = struct { + ServerAddress string + Backend string + }{} + + defer request.Body.Close() + + decoder := json.NewDecoder(request.Body) + err := decoder.Decode(&definition) + if err != nil { + logrus.WithError(err).Error("Unable to get request body") + writer.WriteHeader(http.StatusBadRequest) + return + } + + Routes.CreateMapping(definition.ServerAddress, definition.Backend) + writer.WriteHeader(http.StatusCreated) +} + +type IRoutes interface { + RegisterAll(mappings map[string]string) + // FindBackendForServerAddress returns the host:port for the external server address, if registered. + // Otherwise, an empty string is returned + FindBackendForServerAddress(serverAddress string) string + GetMappings() map[string]string + DeleteMapping(serverAddress string) bool + CreateMapping(serverAddress string, backend string) +} + +var Routes IRoutes = &routesImpl{} + +func (r *routesImpl) RegisterAll(mappings map[string]string) { + r.Lock() + defer r.Unlock() + + r.mappings = mappings +} + +type routesImpl struct { + sync.RWMutex + mappings map[string]string +} + +func (r *routesImpl) FindBackendForServerAddress(serverAddress string) string { + r.RLock() + defer r.RUnlock() + + if r.mappings == nil { + return "" + } else { + return r.mappings[serverAddress] + } +} + +func (r *routesImpl) GetMappings() map[string]string { + r.RLock() + defer r.RUnlock() + + result := make(map[string]string, len(r.mappings)) + for k, v := range r.mappings { + result[k] = v + } + return result +} + +func (r *routesImpl) DeleteMapping(serverAddress string) bool { + r.Lock() + defer r.Unlock() + logrus.WithField("serverAddress", serverAddress).Info("Deleting route") + + if _, ok := r.mappings[serverAddress]; ok { + delete(r.mappings, serverAddress) + return true + } else { + return false + } +} + +func (r *routesImpl) CreateMapping(serverAddress string, backend string) { + r.Lock() + defer r.Unlock() + + logrus.WithFields(logrus.Fields{ + "serverAddress": serverAddress, + "backend": backend, + }).Info("Creating route") + r.mappings[serverAddress] = backend +} \ No newline at end of file