From cc590524c4f402ffec3065551b3930b169c3bfe3 Mon Sep 17 00:00:00 2001 From: Geoff Bourne Date: Mon, 21 Apr 2025 20:28:34 -0500 Subject: [PATCH] Add connection webhook notifications (#392) Also * Added decode of LoginStart message * Add metrics backend constants * Updated usage section * Documented MaxFrameLength --- README.md | 100 +++++++++++++++++++- cmd/mc-router/main.go | 20 +++- cmd/mc-router/metrics.go | 18 +++- mcproto/decode.go | 67 ++++++++++++++ mcproto/handshake-login-start.hex | 2 + mcproto/handshake-status.hex | 2 + mcproto/read.go | 56 ++++------- mcproto/read_test.go | 73 +++++++++++++++ mcproto/types.go | 25 +++-- server/client_filter.go | 4 + server/connector.go | 117 ++++++++++++++++++++--- server/notifier.go | 19 ++++ server/webhook_notifier.go | 148 ++++++++++++++++++++++++++++++ 13 files changed, 585 insertions(+), 66 deletions(-) create mode 100644 mcproto/decode.go create mode 100644 mcproto/handshake-login-start.hex create mode 100644 mcproto/handshake-status.hex create mode 100644 server/notifier.go create mode 100644 server/webhook_notifier.go diff --git a/README.md b/README.md index e2030d7..ccd1b04 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Routes Minecraft client connections to backend servers based upon the requested -mapping value Comma or newline delimited or repeated mappings of externalHostname=host:port (env MAPPING) -metrics-backend string - Backend to use for metrics exposure/publishing: discard,expvar,influxdb (env METRICS_BACKEND) (default "discard") + Backend to use for metrics exposure/publishing: discard,expvar,influxdb,prometheus (env METRICS_BACKEND) (default "discard") -metrics-backend-config-influxdb-addr string (env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR) -metrics-backend-config-influxdb-database string @@ -74,9 +74,12 @@ Routes Minecraft client connections to backend servers based upon the requested Send PROXY protocol to backend servers (env USE_PROXY_PROTOCOL) -version Output version and exit (env VERSION) + -webhook-require-user + Indicates if the webhook will only be called if a user is connecting rather than just server list/ping (env WEBHOOK_REQUIRE_USER) + -webhook-url string + If set, a POST request that contains connection status notifications will be sent to this HTTP address (env WEBHOOK_URL) ``` - ## Docker Multi-Architecture Image The [multi-architecture image published at Docker Hub](https://hub.docker.com/repository/docker/itzg/mc-router) supports amd64, arm64, and arm32v6 (i.e. RaspberryPi). @@ -353,6 +356,99 @@ From those logs, locate the `ngrokUrl` parameter from the "Listening" info log m In the Minecraft client, the server address will be the part after the "tcp://" prefix, such as `8.tcp.ngrok.io:99999`. +## Webhook Support + +Refer to [the usage section above](#usage) for `-webhook-*` argument descriptions. + +### Sample connect event payloads + +The following are sample payloads for the `connect` webhook events. + +#### Successful player backend connection + +```json +{ + "event": "connect", + "timestamp": "2025-04-20T22:26:30.2568775-05:00", + "status": "success", + "client": { + "host": "127.0.0.1", + "port": 56860 + }, + "server": "localhost", + "player": { + "name": "itzg", + "uuid": "5cddfd26-fc86-4981-b52e-c42bb10bfdef" + }, + "backend": "localhost:25566" +} +``` + +**NOTE** `client` refers to the machine where the Minecraft client is connecting from and is conveyed separately from the `player` starting a session. As seen below, the player information may not always be present, such as when the client is pinging the server list. + +#### Successful server ping backend connection + +**NOTE** the absence of `player` in this payload since the Minecraft client does not send player information in the server ping request. + +```json +{ + "event": "connect", + "timestamp": "2025-04-20T22:26:30.2568775-05:00", + "status": "success", + "client": { + "host": "127.0.0.1", + "port": 56396 + }, + "server": "localhost", + "backend": "localhost:25566" +} +``` + +#### Missing backend + +In this the status is `"missing-backend"` since the requested server `invalid.example.com` does not have a configured/discovered backend entry. + +```json +{ + "event": "connect", + "timestamp": "2025-04-20T22:26:30.2568775-05:00", + "status": "missing-backend", + "client": { + "host": "127.0.0.1", + "port": 56891 + }, + "server": "invalid.example.com", + "player": { + "name": "itzg", + "uuid": "5cddfd26-fc86-4981-b52e-c42bb10bfdef" + }, + "error": "No backend found" +} +``` + +#### Failed backend connection + +In this case the `status` is `"failed-backend-connection"` indicating that a backend server was located but a connection could not be established from mc-router. + +```json +{ + "event": "connect", + "timestamp": "2025-04-20T22:26:30.2568775-05:00", + "status": "failed-backend-connection", + "client": { + "host": "127.0.0.1", + "port": 56905 + }, + "server": "localhost", + "player": { + "name": "itzg", + "uuid": "5cddfd26-fc86-4981-b52e-c42bb10bfdef" + }, + "backend": "localhost:25566", + "error": "dial tcp [::1]:25566: connectex: No connection could be made because the target machine actively refused it." +} +``` + ## Development ### Building locally with Docker diff --git a/cmd/mc-router/main.go b/cmd/mc-router/main.go index dc54421..d4b6a68 100644 --- a/cmd/mc-router/main.go +++ b/cmd/mc-router/main.go @@ -28,6 +28,11 @@ type MetricsBackendConfig struct { } } +type WebhookConfig struct { + Url string `usage:"If set, a POST request that contains connection status notifications will be sent to this HTTP address"` + RequireUser bool `default:"false" usage:"Indicates if the webhook will only be called if a user is connecting rather than just server list/ping"` +} + type Config struct { Port int `default:"25565" usage:"The [port] bound to listen for Minecraft client connections"` Default string `usage:"host:port of a default Minecraft server to use when mapping not found"` @@ -57,6 +62,8 @@ type Config struct { ClientsToDeny []string `usage:"Zero or more client IP addresses or CIDRs to deny. Ignored if any configured to allow"` SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"` + + Webhook WebhookConfig `usage:"Webhook configuration"` } var ( @@ -135,12 +142,23 @@ func main() { trustedIpNets = append(trustedIpNets, ipNet) } + connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets) + clientFilter, err := server.NewClientFilter(config.ClientsToAllow, config.ClientsToDeny) if err != nil { logrus.WithError(err).Fatal("Unable to create client filter") } + connector.SetClientFilter(clientFilter) + + if config.Webhook.Url != "" { + logrus. + WithField("url", config.Webhook.Url). + WithField("require-user", config.Webhook.RequireUser). + Info("Using webhook for connection status notifications") + connector.SetConnectionNotifier( + server.NewWebhookNotifier(config.Webhook.Url, config.Webhook.RequireUser)) + } - connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets, clientFilter) if config.NgrokToken != "" { connector.UseNgrok(config.NgrokToken) } diff --git a/cmd/mc-router/metrics.go b/cmd/mc-router/metrics.go index 9cc4a7c..d63dbf4 100644 --- a/cmd/mc-router/metrics.go +++ b/cmd/mc-router/metrics.go @@ -24,14 +24,26 @@ type MetricsBuilder interface { Start(ctx context.Context) error } +const ( + MetricsBackendExpvar = "expvar" + MetricsBackendPrometheus = "prometheus" + MetricsBackendInfluxDB = "influxdb" + MetricsBackendDiscard = "discard" +) + +// NewMetricsBuilder creates a new MetricsBuilder based on the specified backend. +// If the backend is not recognized, a discard builder is returned. +// config can be nil if the backend is not influxdb. func NewMetricsBuilder(backend string, config *MetricsBackendConfig) MetricsBuilder { switch strings.ToLower(backend) { - case "expvar": + case MetricsBackendExpvar: return &expvarMetricsBuilder{} - case "prometheus": + case MetricsBackendPrometheus: return &prometheusMetricsBuilder{} - case "influxdb": + case MetricsBackendInfluxDB: return &influxMetricsBuilder{config: config} + case MetricsBackendDiscard: + return &discardMetricsBuilder{} default: return &discardMetricsBuilder{} } diff --git a/mcproto/decode.go b/mcproto/decode.go new file mode 100644 index 0000000..901af58 --- /dev/null +++ b/mcproto/decode.go @@ -0,0 +1,67 @@ +package mcproto + +import ( + "bytes" + "github.com/pkg/errors" +) + +const invalidPacketDataBytesMsg = "data should be byte slice from Packet.Data" + +// DecodeHandshake takes the Packet.Data bytes and decodes a Handshake message from it +func DecodeHandshake(data interface{}) (*Handshake, error) { + + dataBytes, ok := data.([]byte) + if !ok { + return nil, errors.New(invalidPacketDataBytesMsg) + } + + handshake := &Handshake{} + buffer := bytes.NewBuffer(dataBytes) + 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 = State(nextState) + return handshake, nil +} + +// DecodeLoginStart takes the Packet.Data bytes and decodes a LoginStart message from it +func DecodeLoginStart(data interface{}) (*LoginStart, error) { + dataBytes, ok := data.([]byte) + if !ok { + return nil, errors.New(invalidPacketDataBytesMsg) + } + + loginStart := &LoginStart{} + buffer := bytes.NewBuffer(dataBytes) + var err error + + loginStart.Name, err = ReadString(buffer) + if err != nil { + return nil, errors.Wrap(err, "failed to read username") + } + + loginStart.PlayerUuid, err = ReadUuid(buffer) + if err != nil { + return nil, errors.Wrap(err, "failed to read player uuid") + } + + return loginStart, nil +} diff --git a/mcproto/handshake-login-start.hex b/mcproto/handshake-login-start.hex new file mode 100644 index 0000000..a8dc197 --- /dev/null +++ b/mcproto/handshake-login-start.hex @@ -0,0 +1,2 @@ +10008206096c6f63616c686f737463dd02 +16000469747a675cddfd26fc864981b52ec42bb10bfdef \ No newline at end of file diff --git a/mcproto/handshake-status.hex b/mcproto/handshake-status.hex new file mode 100644 index 0000000..a1ee9d9 --- /dev/null +++ b/mcproto/handshake-status.hex @@ -0,0 +1,2 @@ +10008206096c6f63616c686f737463dd01 +0100 \ No newline at end of file diff --git a/mcproto/read.go b/mcproto/read.go index 4aedc48..d410af0 100644 --- a/mcproto/read.go +++ b/mcproto/read.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "encoding/binary" + "github.com/google/uuid" "io" "net" "strings" @@ -15,22 +16,27 @@ import ( "golang.org/x/text/transform" ) -func ReadPacket(reader io.Reader, addr net.Addr, state State) (*Packet, error) { +// MaxFrameLength is declared at https://minecraft.wiki/w/Java_Edition_protocol#Packet_format +// to be 2^21 - 1 +const MaxFrameLength = 2097151 + +// ReadPacket reads a packet from the given reader based on the provided connection state. +// Returns a pointer to the Packet and an error if reading fails. +// Handles legacy server list ping packet when in the handshaking state. +// The provided addr is used for logging purposes. +func ReadPacket(reader *bufio.Reader, addr net.Addr, state State) (*Packet, error) { logrus. WithField("client", addr). Debug("Reading packet") if state == StateHandshaking { - bufReader := bufio.NewReader(reader) - data, err := bufReader.Peek(1) + data, err := reader.Peek(1) if err != nil { return nil, err } if data[0] == PacketIdLegacyServerListPing { - return ReadLegacyServerListPing(bufReader, addr) - } else { - reader = bufReader + return ReadLegacyServerListPing(reader, addr) } } @@ -161,8 +167,7 @@ func ReadFrame(reader io.Reader, addr net.Addr) (*Frame, error) { return nil, err } - // Limit frame length to 2^21 - 1 - if frame.Length > 2097151 { + if frame.Length > MaxFrameLength { return nil, errors.Errorf("frame length %d too large", frame.Length) } @@ -280,36 +285,11 @@ func ReadUnsignedInt(reader io.Reader) (uint32, error) { return value, nil } -func ReadHandshake(data interface{}) (*Handshake, error) { - - dataBytes, ok := data.([]byte) - if !ok { - return nil, errors.New("data is not expected byte slice") - } - - handshake := &Handshake{} - buffer := bytes.NewBuffer(dataBytes) - var err error - - handshake.ProtocolVersion, err = ReadVarInt(buffer) +func ReadUuid(reader io.Reader) (uuid.UUID, error) { + uuidBytes := make([]byte, 16) + _, err := io.ReadFull(reader, uuidBytes) if err != nil { - return nil, err + return uuid.UUID{}, 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 + return uuid.FromBytes(uuidBytes) } diff --git a/mcproto/read_test.go b/mcproto/read_test.go index d8f0096..a745a21 100644 --- a/mcproto/read_test.go +++ b/mcproto/read_test.go @@ -1,8 +1,15 @@ package mcproto import ( + "bufio" "bytes" + "encoding/hex" + "fmt" + "github.com/google/uuid" + "os" + "strings" "testing" + "unicode" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,3 +42,69 @@ func TestReadVarInt(t *testing.T) { }) } } + +func TestHandshakeThenStatus(t *testing.T) { + content, err := ReadHexDumpFile("handshake-status.hex") + require.NoError(t, err) + + reader := bufio.NewReader(bytes.NewReader(content)) + + handshakePacket, err := ReadPacket(reader, nil, StateHandshaking) + require.NoError(t, err) + + handshake, err := DecodeHandshake(handshakePacket.Data) + require.NoError(t, err) + + assert.Equal(t, "localhost", handshake.ServerAddress) + assert.Equal(t, uint16(25565), handshake.ServerPort) + assert.Equal(t, 770 /*for 1.21.5*/, handshake.ProtocolVersion) + assert.Equal(t, StateStatus, handshake.NextState) +} + +func TestHandshakeThenLoginStart(t *testing.T) { + content, err := ReadHexDumpFile("handshake-login-start.hex") + require.NoError(t, err) + + reader := bufio.NewReader(bytes.NewReader(content)) + + handshakePacket, err := ReadPacket(reader, nil, StateHandshaking) + require.NoError(t, err) + + handshake, err := DecodeHandshake(handshakePacket.Data) + require.NoError(t, err) + + assert.Equal(t, "localhost", handshake.ServerAddress) + assert.Equal(t, uint16(25565), handshake.ServerPort) + assert.Equal(t, 770 /*for 1.21.5*/, handshake.ProtocolVersion) + assert.Equal(t, StateLogin, handshake.NextState) + + loginStartPacket, err := ReadPacket(reader, nil, StateLogin) + require.NoError(t, err) + + loginStart, err := DecodeLoginStart(loginStartPacket.Data) + require.NoError(t, err) + + assert.Equal(t, "itzg", loginStart.Name) + assert.Equal(t, uuid.MustParse("5cddfd26-fc86-4981-b52e-c42bb10bfdef"), loginStart.PlayerUuid) +} + +func ReadHexDumpFile(filename string) ([]byte, error) { + // Read the file content + content, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Convert content to string and clean it up + hexString := string(content) + + // Remove whitespace and newlines + hexString = strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 // Remove spaces, tabs, newlines + } + return r + }, hexString) + + return hex.DecodeString(hexString) +} diff --git a/mcproto/types.go b/mcproto/types.go index ffd0715..f9339a8 100644 --- a/mcproto/types.go +++ b/mcproto/types.go @@ -1,6 +1,9 @@ package mcproto -import "fmt" +import ( + "fmt" + "github.com/google/uuid" +) type Frame struct { Length int @@ -9,8 +12,14 @@ type Frame struct { type State int +/* +Handshaking -> Status +Handshaking -> Login -> ... +*/ const ( - StateHandshaking = iota + StateHandshaking State = 0 + StateStatus State = 1 + StateLogin State = 2 ) var trimLimit = 64 @@ -31,7 +40,7 @@ func (f *Frame) String() string { type Packet struct { Length int PacketID int - // Data is either a byte slice of raw content or a parsed message + // Data is either a byte slice of raw content or a decoded message Data interface{} } @@ -46,6 +55,7 @@ func (p *Packet) String() string { const ( PacketIdHandshake = 0x00 + PacketIdLogin = 0x00 // during StateLogin PacketIdLegacyServerListPing = 0xFE ) @@ -53,12 +63,13 @@ type Handshake struct { ProtocolVersion int ServerAddress string ServerPort uint16 - NextState int + NextState State } -const ( - StateStatus State = 1 -) +type LoginStart struct { + Name string + PlayerUuid uuid.UUID +} type LegacyServerListPing struct { ProtocolVersion int diff --git a/server/client_filter.go b/server/client_filter.go index f0eade2..33b7bf4 100644 --- a/server/client_filter.go +++ b/server/client_filter.go @@ -67,6 +67,10 @@ type ClientFilter struct { deny *addrMatcher } +func NewClientFilterAllowAll() *ClientFilter { + return &ClientFilter{} +} + // NewClientFilter provides a mechanism to evaluate client IP addresses and determine if // they should be allowed access or not. // The allows and denies can each or both be nil or netip.ParseAddr allowed values. diff --git a/server/connector.go b/server/connector.go index 8e8f1a1..ce296b5 100644 --- a/server/connector.go +++ b/server/connector.go @@ -1,8 +1,11 @@ package server import ( + "bufio" "bytes" "context" + "fmt" + "github.com/google/uuid" "io" "net" "sync" @@ -33,15 +36,34 @@ type ConnectorMetrics struct { ActiveConnections metrics.Gauge } -func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet, - clientFilter *ClientFilter) *Connector { +type ClientInfo struct { + Host string `json:"host"` + Port int `json:"port"` +} + +func ClientInfoFromAddr(addr net.Addr) *ClientInfo { + if addr == nil { + return nil + } + + return &ClientInfo{ + Host: addr.(*net.TCPAddr).IP.String(), + Port: addr.(*net.TCPAddr).Port, + } +} + +type PlayerInfo struct { + Name string `json:"name"` + Uuid uuid.UUID `json:"uuid"` +} + +func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet) *Connector { return &Connector{ metrics: metrics, sendProxyProto: sendProxyProto, connectionsCond: sync.NewCond(&sync.Mutex{}), receiveProxyProto: receiveProxyProto, trustedProxyNets: trustedProxyNets, - clientFilter: clientFilter, } } @@ -56,6 +78,16 @@ type Connector struct { connectionsCond *sync.Cond ngrokToken string clientFilter *ClientFilter + + connectionNotifier ConnectionNotifier +} + +func (c *Connector) SetConnectionNotifier(notifier ConnectionNotifier) { + c.connectionNotifier = notifier +} + +func (c *Connector) SetClientFilter(filter *ClientFilter) { + c.clientFilter = filter } func (c *Connector) StartAcceptingConnections(ctx context.Context, listenAddress string, connRateLimit int) error { @@ -169,10 +201,12 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) clientAddr := frontendConn.RemoteAddr() if tcpAddr, ok := clientAddr.(*net.TCPAddr); ok { - allow := c.clientFilter.Allow(tcpAddr.AddrPort()) - if !allow { - logrus.WithField("client", clientAddr).Debug("Client is blocked") - return + if c.clientFilter != nil { + allow := c.clientFilter.Allow(tcpAddr.AddrPort()) + if !allow { + logrus.WithField("client", clientAddr).Debug("Client is blocked") + return + } } } else { logrus.WithField("client", clientAddr).Warn("Remote address is not a TCP address, skipping filtering") @@ -183,10 +217,12 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) Info("Got connection") defer logrus.WithField("client", clientAddr).Debug("Closing frontend connection") + // Tee-off the inspected content to a buffer so that we can retransmit it to the backend connection inspectionBuffer := new(bytes.Buffer) - inspectionReader := io.TeeReader(frontendConn, inspectionBuffer) + bufferedReader := bufio.NewReader(inspectionReader) + if err := frontendConn.SetReadDeadline(time.Now().Add(handshakeTimeout)); err != nil { logrus. WithError(err). @@ -195,7 +231,7 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) c.metrics.Errors.With("type", "read_deadline").Add(1) return } - packet, err := mcproto.ReadPacket(inspectionReader, clientAddr, c.state) + packet, err := mcproto.ReadPacket(bufferedReader, clientAddr, c.state) if err != nil { logrus.WithError(err).WithField("clientAddr", clientAddr).Error("Failed to read packet") c.metrics.Errors.With("type", "read").Add(1) @@ -209,7 +245,7 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) Debug("Got packet") if packet.PacketID == mcproto.PacketIdHandshake { - handshake, err := mcproto.ReadHandshake(packet.Data) + handshake, err := mcproto.DecodeHandshake(packet.Data) if err != nil { logrus.WithError(err).WithField("clientAddr", clientAddr). Error("Failed to read handshake") @@ -222,10 +258,25 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) WithField("handshake", handshake). Debug("Got handshake") - serverAddress := handshake.ServerAddress - nextState := mcproto.State(handshake.NextState) + var userInfo *PlayerInfo = nil + if handshake.NextState == mcproto.StateLogin { + userInfo, err = c.readUserInfo(bufferedReader, clientAddr, handshake.NextState) + if err != nil { + logrus. + WithError(err). + WithField("clientAddr", clientAddr). + Error("Failed to read user info") + c.metrics.Errors.With("type", "read").Add(1) + return + } + logrus. + WithField("client", clientAddr). + WithField("userInfo", userInfo). + Debug("Got user info") + } + + c.findAndConnectBackend(ctx, frontendConn, clientAddr, inspectionBuffer, handshake.ServerAddress, userInfo, handshake.NextState) - c.findAndConnectBackend(ctx, frontendConn, clientAddr, inspectionBuffer, serverAddress, nextState) } else if packet.PacketID == mcproto.PacketIdLegacyServerListPing { handshake, ok := packet.Data.(*mcproto.LegacyServerListPing) if !ok { @@ -244,7 +295,7 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) serverAddress := handshake.ServerAddress - c.findAndConnectBackend(ctx, frontendConn, clientAddr, inspectionBuffer, serverAddress, mcproto.StateStatus) + c.findAndConnectBackend(ctx, frontendConn, clientAddr, inspectionBuffer, serverAddress, nil, mcproto.StateStatus) } else { logrus. WithField("client", clientAddr). @@ -255,8 +306,28 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) } } +func (c *Connector) readUserInfo(bufferedReader *bufio.Reader, clientAddr net.Addr, state mcproto.State) (*PlayerInfo, error) { + loginPacket, err := mcproto.ReadPacket(bufferedReader, clientAddr, state) + if err != nil { + return nil, fmt.Errorf("failed to read login packet: %w", err) + } + + if loginPacket.PacketID == mcproto.PacketIdLogin { + loginStart, err := mcproto.DecodeLoginStart(loginPacket.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode login start: %w", err) + } + return &PlayerInfo{ + Name: loginStart.Name, + Uuid: loginStart.PlayerUuid, + }, nil + } else { + return nil, fmt.Errorf("expected login packet, got %d", loginPacket.PacketID) + } +} + func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net.Conn, - clientAddr net.Addr, preReadContent io.Reader, serverAddress string, nextState mcproto.State) { + clientAddr net.Addr, preReadContent io.Reader, serverAddress string, userInfo *PlayerInfo, nextState mcproto.State) { backendHostPort, resolvedHost, waker := Routes.FindBackendForServerAddress(ctx, serverAddress) if waker != nil && nextState > mcproto.StateStatus { @@ -273,13 +344,20 @@ func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net. WithField("resolvedHost", resolvedHost). Warn("Unable to find registered backend") c.metrics.Errors.With("type", "missing_backend").Add(1) + + if c.connectionNotifier != nil { + c.connectionNotifier.NotifyMissingBackend(ctx, clientAddr, serverAddress, userInfo) + } + return } + logrus. WithField("client", clientAddr). WithField("server", serverAddress). WithField("backendHostPort", backendHostPort). Info("Connecting to backend") + backendConn, err := net.Dial("tcp", backendHostPort) if err != nil { logrus. @@ -289,9 +367,18 @@ func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net. WithField("backend", backendHostPort). Warn("Unable to connect to backend") c.metrics.Errors.With("type", "backend_failed").Add(1) + + if c.connectionNotifier != nil { + c.connectionNotifier.NotifyFailedBackendConnection(ctx, clientAddr, serverAddress, userInfo, backendHostPort, err) + } + return } + if c.connectionNotifier != nil { + c.connectionNotifier.NotifyConnected(ctx, clientAddr, serverAddress, userInfo, backendHostPort) + } + c.metrics.ConnectionsBackend.With("host", resolvedHost).Add(1) c.metrics.ActiveConnections.Set(float64( diff --git a/server/notifier.go b/server/notifier.go new file mode 100644 index 0000000..db01f19 --- /dev/null +++ b/server/notifier.go @@ -0,0 +1,19 @@ +package server + +import ( + "context" + "net" +) + +type ConnectionNotifier interface { + // NotifyMissingBackend is called when an inbound connection is received for a server that does not have a backend. + NotifyMissingBackend(ctx context.Context, clientAddr net.Addr, server string, playerInfo *PlayerInfo) error + + // NotifyFailedBackendConnection is called when the backend connection failed. + NotifyFailedBackendConnection(ctx context.Context, + clientAddr net.Addr, serverAddress string, playerInfo *PlayerInfo, backendHostPort string, err error) error + + // NotifyConnected is called when the backend connection succeeded. + NotifyConnected(ctx context.Context, + clientAddr net.Addr, serverAddress string, playerInfo *PlayerInfo, backendHostPort string) error +} diff --git a/server/webhook_notifier.go b/server/webhook_notifier.go new file mode 100644 index 0000000..c58c130 --- /dev/null +++ b/server/webhook_notifier.go @@ -0,0 +1,148 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/sirupsen/logrus" + "log" + "net" + "net/http" + "time" +) + +// WebhookNotifier implements ConnectionNotifier by sending a POST request to a webhook URL. +// The payload is a JSON object defined by WebhookNotifierPayload. +type WebhookNotifier struct { + url string + requireUser bool + + client *http.Client +} + +const ( + WebhookEventConnecting = "connect" +) + +const ( + WebhookStatusMissingBackend = "missing-backend" + WebhookStatusFailedBackendConnection = "failed-backend-connection" + WebhookStatusSuccess = "success" +) + +type WebhookNotifierPayload struct { + Event string `json:"event"` + Timestamp time.Time `json:"timestamp"` + Status string `json:"status"` + Client *ClientInfo `json:"client"` + Server string `json:"server"` + PlayerInfo *PlayerInfo `json:"player,omitempty"` + BackendHostPort string `json:"backend,omitempty"` + Error string `json:"error,omitempty"` +} + +func NewWebhookNotifier(url string, requireUser bool) *WebhookNotifier { + + return &WebhookNotifier{ + url: url, + requireUser: requireUser, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (w *WebhookNotifier) NotifyMissingBackend(ctx context.Context, clientAddr net.Addr, server string, playerInfo *PlayerInfo) error { + if w.requireUser && playerInfo == nil { + return nil + } + + payload := &WebhookNotifierPayload{ + Event: WebhookEventConnecting, + Timestamp: time.Now(), + Status: WebhookStatusMissingBackend, + Client: ClientInfoFromAddr(clientAddr), + Server: server, + PlayerInfo: playerInfo, + Error: "No backend found", + } + + return w.send(ctx, payload) +} + +func (w *WebhookNotifier) NotifyFailedBackendConnection(ctx context.Context, clientAddr net.Addr, server string, + playerInfo *PlayerInfo, backendHostPort string, err error) error { + if w.requireUser && playerInfo == nil { + return nil + } + + payload := &WebhookNotifierPayload{ + Event: WebhookEventConnecting, + Timestamp: time.Now(), + Status: WebhookStatusFailedBackendConnection, + Client: ClientInfoFromAddr(clientAddr), + Server: server, + PlayerInfo: playerInfo, + BackendHostPort: backendHostPort, + Error: err.Error(), + } + + return w.send(ctx, payload) +} + +func (w *WebhookNotifier) NotifyConnected(ctx context.Context, clientAddr net.Addr, serverAddress string, playerInfo *PlayerInfo, backendHostPort string) error { + if w.requireUser && playerInfo == nil { + return nil + } + + payload := &WebhookNotifierPayload{ + Event: WebhookEventConnecting, + Timestamp: time.Now(), + Status: WebhookStatusSuccess, + Client: ClientInfoFromAddr(clientAddr), + Server: serverAddress, + PlayerInfo: playerInfo, + BackendHostPort: backendHostPort, + } + + return w.send(ctx, payload) +} + +func (w *WebhookNotifier) send(ctx context.Context, payload *WebhookNotifierPayload) error { + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %v", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + w.url, + bytes.NewBuffer(jsonPayload), + ) + if err != nil { + return fmt.Errorf("failed to create webhook request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + go func() { + resp, err := w.client.Do(req) + if err != nil { + // Handle error + log.Printf("Failed to send webhook notification: %v", err) + return + } + _ = resp.Body.Close() + + if resp.StatusCode >= 400 { + logrus. + WithField("status", resp.StatusCode). + Warn("webhook receiver responded with an error") + } + + }() + + return nil +}