Add connection webhook notifications (#392)
Also * Added decode of LoginStart message * Add metrics backend constants * Updated usage section * Documented MaxFrameLength
This commit is contained in:
@@ -43,7 +43,7 @@ Routes Minecraft client connections to backend servers based upon the requested
|
|||||||
-mapping value
|
-mapping value
|
||||||
Comma or newline delimited or repeated mappings of externalHostname=host:port (env MAPPING)
|
Comma or newline delimited or repeated mappings of externalHostname=host:port (env MAPPING)
|
||||||
-metrics-backend string
|
-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
|
-metrics-backend-config-influxdb-addr string
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR)
|
||||||
-metrics-backend-config-influxdb-database string
|
-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)
|
Send PROXY protocol to backend servers (env USE_PROXY_PROTOCOL)
|
||||||
-version
|
-version
|
||||||
Output version and exit (env 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
|
## 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).
|
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`.
|
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
|
## Development
|
||||||
|
|
||||||
### Building locally with Docker
|
### Building locally with Docker
|
||||||
|
|||||||
+19
-1
@@ -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 {
|
type Config struct {
|
||||||
Port int `default:"25565" usage:"The [port] bound to listen for Minecraft client connections"`
|
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"`
|
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"`
|
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"`
|
SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"`
|
||||||
|
|
||||||
|
Webhook WebhookConfig `usage:"Webhook configuration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -135,12 +142,23 @@ func main() {
|
|||||||
trustedIpNets = append(trustedIpNets, ipNet)
|
trustedIpNets = append(trustedIpNets, ipNet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets)
|
||||||
|
|
||||||
clientFilter, err := server.NewClientFilter(config.ClientsToAllow, config.ClientsToDeny)
|
clientFilter, err := server.NewClientFilter(config.ClientsToAllow, config.ClientsToDeny)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Fatal("Unable to create client filter")
|
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 != "" {
|
if config.NgrokToken != "" {
|
||||||
connector.UseNgrok(config.NgrokToken)
|
connector.UseNgrok(config.NgrokToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,26 @@ type MetricsBuilder interface {
|
|||||||
Start(ctx context.Context) error
|
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 {
|
func NewMetricsBuilder(backend string, config *MetricsBackendConfig) MetricsBuilder {
|
||||||
switch strings.ToLower(backend) {
|
switch strings.ToLower(backend) {
|
||||||
case "expvar":
|
case MetricsBackendExpvar:
|
||||||
return &expvarMetricsBuilder{}
|
return &expvarMetricsBuilder{}
|
||||||
case "prometheus":
|
case MetricsBackendPrometheus:
|
||||||
return &prometheusMetricsBuilder{}
|
return &prometheusMetricsBuilder{}
|
||||||
case "influxdb":
|
case MetricsBackendInfluxDB:
|
||||||
return &influxMetricsBuilder{config: config}
|
return &influxMetricsBuilder{config: config}
|
||||||
|
case MetricsBackendDiscard:
|
||||||
|
return &discardMetricsBuilder{}
|
||||||
default:
|
default:
|
||||||
return &discardMetricsBuilder{}
|
return &discardMetricsBuilder{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
10008206096c6f63616c686f737463dd02
|
||||||
|
16000469747a675cddfd26fc864981b52ec42bb10bfdef
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
10008206096c6f63616c686f737463dd01
|
||||||
|
0100
|
||||||
+18
-38
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"github.com/google/uuid"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,22 +16,27 @@ import (
|
|||||||
"golang.org/x/text/transform"
|
"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.
|
logrus.
|
||||||
WithField("client", addr).
|
WithField("client", addr).
|
||||||
Debug("Reading packet")
|
Debug("Reading packet")
|
||||||
|
|
||||||
if state == StateHandshaking {
|
if state == StateHandshaking {
|
||||||
bufReader := bufio.NewReader(reader)
|
data, err := reader.Peek(1)
|
||||||
data, err := bufReader.Peek(1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if data[0] == PacketIdLegacyServerListPing {
|
if data[0] == PacketIdLegacyServerListPing {
|
||||||
return ReadLegacyServerListPing(bufReader, addr)
|
return ReadLegacyServerListPing(reader, addr)
|
||||||
} else {
|
|
||||||
reader = bufReader
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,8 +167,7 @@ func ReadFrame(reader io.Reader, addr net.Addr) (*Frame, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit frame length to 2^21 - 1
|
if frame.Length > MaxFrameLength {
|
||||||
if frame.Length > 2097151 {
|
|
||||||
return nil, errors.Errorf("frame length %d too large", frame.Length)
|
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
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadHandshake(data interface{}) (*Handshake, error) {
|
func ReadUuid(reader io.Reader) (uuid.UUID, error) {
|
||||||
|
uuidBytes := make([]byte, 16)
|
||||||
dataBytes, ok := data.([]byte)
|
_, err := io.ReadFull(reader, uuidBytes)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
|
return uuid.FromBytes(uuidBytes)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package mcproto
|
package mcproto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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)
|
||||||
|
}
|
||||||
|
|||||||
+18
-7
@@ -1,6 +1,9 @@
|
|||||||
package mcproto
|
package mcproto
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
type Frame struct {
|
type Frame struct {
|
||||||
Length int
|
Length int
|
||||||
@@ -9,8 +12,14 @@ type Frame struct {
|
|||||||
|
|
||||||
type State int
|
type State int
|
||||||
|
|
||||||
|
/*
|
||||||
|
Handshaking -> Status
|
||||||
|
Handshaking -> Login -> ...
|
||||||
|
*/
|
||||||
const (
|
const (
|
||||||
StateHandshaking = iota
|
StateHandshaking State = 0
|
||||||
|
StateStatus State = 1
|
||||||
|
StateLogin State = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
var trimLimit = 64
|
var trimLimit = 64
|
||||||
@@ -31,7 +40,7 @@ func (f *Frame) String() string {
|
|||||||
type Packet struct {
|
type Packet struct {
|
||||||
Length int
|
Length int
|
||||||
PacketID 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{}
|
Data interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +55,7 @@ func (p *Packet) String() string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
PacketIdHandshake = 0x00
|
PacketIdHandshake = 0x00
|
||||||
|
PacketIdLogin = 0x00 // during StateLogin
|
||||||
PacketIdLegacyServerListPing = 0xFE
|
PacketIdLegacyServerListPing = 0xFE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,12 +63,13 @@ type Handshake struct {
|
|||||||
ProtocolVersion int
|
ProtocolVersion int
|
||||||
ServerAddress string
|
ServerAddress string
|
||||||
ServerPort uint16
|
ServerPort uint16
|
||||||
NextState int
|
NextState State
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
type LoginStart struct {
|
||||||
StateStatus State = 1
|
Name string
|
||||||
)
|
PlayerUuid uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
type LegacyServerListPing struct {
|
type LegacyServerListPing struct {
|
||||||
ProtocolVersion int
|
ProtocolVersion int
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ type ClientFilter struct {
|
|||||||
deny *addrMatcher
|
deny *addrMatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewClientFilterAllowAll() *ClientFilter {
|
||||||
|
return &ClientFilter{}
|
||||||
|
}
|
||||||
|
|
||||||
// NewClientFilter provides a mechanism to evaluate client IP addresses and determine if
|
// NewClientFilter provides a mechanism to evaluate client IP addresses and determine if
|
||||||
// they should be allowed access or not.
|
// they should be allowed access or not.
|
||||||
// The allows and denies can each or both be nil or netip.ParseAddr allowed values.
|
// The allows and denies can each or both be nil or netip.ParseAddr allowed values.
|
||||||
|
|||||||
+102
-15
@@ -1,8 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/uuid"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -33,15 +36,34 @@ type ConnectorMetrics struct {
|
|||||||
ActiveConnections metrics.Gauge
|
ActiveConnections metrics.Gauge
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet,
|
type ClientInfo struct {
|
||||||
clientFilter *ClientFilter) *Connector {
|
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{
|
return &Connector{
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
sendProxyProto: sendProxyProto,
|
sendProxyProto: sendProxyProto,
|
||||||
connectionsCond: sync.NewCond(&sync.Mutex{}),
|
connectionsCond: sync.NewCond(&sync.Mutex{}),
|
||||||
receiveProxyProto: receiveProxyProto,
|
receiveProxyProto: receiveProxyProto,
|
||||||
trustedProxyNets: trustedProxyNets,
|
trustedProxyNets: trustedProxyNets,
|
||||||
clientFilter: clientFilter,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +78,16 @@ type Connector struct {
|
|||||||
connectionsCond *sync.Cond
|
connectionsCond *sync.Cond
|
||||||
ngrokToken string
|
ngrokToken string
|
||||||
clientFilter *ClientFilter
|
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 {
|
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()
|
clientAddr := frontendConn.RemoteAddr()
|
||||||
|
|
||||||
if tcpAddr, ok := clientAddr.(*net.TCPAddr); ok {
|
if tcpAddr, ok := clientAddr.(*net.TCPAddr); ok {
|
||||||
allow := c.clientFilter.Allow(tcpAddr.AddrPort())
|
if c.clientFilter != nil {
|
||||||
if !allow {
|
allow := c.clientFilter.Allow(tcpAddr.AddrPort())
|
||||||
logrus.WithField("client", clientAddr).Debug("Client is blocked")
|
if !allow {
|
||||||
return
|
logrus.WithField("client", clientAddr).Debug("Client is blocked")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logrus.WithField("client", clientAddr).Warn("Remote address is not a TCP address, skipping filtering")
|
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")
|
Info("Got connection")
|
||||||
defer logrus.WithField("client", clientAddr).Debug("Closing frontend 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)
|
inspectionBuffer := new(bytes.Buffer)
|
||||||
|
|
||||||
inspectionReader := io.TeeReader(frontendConn, inspectionBuffer)
|
inspectionReader := io.TeeReader(frontendConn, inspectionBuffer)
|
||||||
|
|
||||||
|
bufferedReader := bufio.NewReader(inspectionReader)
|
||||||
|
|
||||||
if err := frontendConn.SetReadDeadline(time.Now().Add(handshakeTimeout)); err != nil {
|
if err := frontendConn.SetReadDeadline(time.Now().Add(handshakeTimeout)); err != nil {
|
||||||
logrus.
|
logrus.
|
||||||
WithError(err).
|
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)
|
c.metrics.Errors.With("type", "read_deadline").Add(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
packet, err := mcproto.ReadPacket(inspectionReader, clientAddr, c.state)
|
packet, err := mcproto.ReadPacket(bufferedReader, clientAddr, c.state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).WithField("clientAddr", clientAddr).Error("Failed to read packet")
|
logrus.WithError(err).WithField("clientAddr", clientAddr).Error("Failed to read packet")
|
||||||
c.metrics.Errors.With("type", "read").Add(1)
|
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")
|
Debug("Got packet")
|
||||||
|
|
||||||
if packet.PacketID == mcproto.PacketIdHandshake {
|
if packet.PacketID == mcproto.PacketIdHandshake {
|
||||||
handshake, err := mcproto.ReadHandshake(packet.Data)
|
handshake, err := mcproto.DecodeHandshake(packet.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).WithField("clientAddr", clientAddr).
|
logrus.WithError(err).WithField("clientAddr", clientAddr).
|
||||||
Error("Failed to read handshake")
|
Error("Failed to read handshake")
|
||||||
@@ -222,10 +258,25 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn)
|
|||||||
WithField("handshake", handshake).
|
WithField("handshake", handshake).
|
||||||
Debug("Got handshake")
|
Debug("Got handshake")
|
||||||
|
|
||||||
serverAddress := handshake.ServerAddress
|
var userInfo *PlayerInfo = nil
|
||||||
nextState := mcproto.State(handshake.NextState)
|
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 {
|
} else if packet.PacketID == mcproto.PacketIdLegacyServerListPing {
|
||||||
handshake, ok := packet.Data.(*mcproto.LegacyServerListPing)
|
handshake, ok := packet.Data.(*mcproto.LegacyServerListPing)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -244,7 +295,7 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn)
|
|||||||
|
|
||||||
serverAddress := handshake.ServerAddress
|
serverAddress := handshake.ServerAddress
|
||||||
|
|
||||||
c.findAndConnectBackend(ctx, frontendConn, clientAddr, inspectionBuffer, serverAddress, mcproto.StateStatus)
|
c.findAndConnectBackend(ctx, frontendConn, clientAddr, inspectionBuffer, serverAddress, nil, mcproto.StateStatus)
|
||||||
} else {
|
} else {
|
||||||
logrus.
|
logrus.
|
||||||
WithField("client", clientAddr).
|
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,
|
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)
|
backendHostPort, resolvedHost, waker := Routes.FindBackendForServerAddress(ctx, serverAddress)
|
||||||
if waker != nil && nextState > mcproto.StateStatus {
|
if waker != nil && nextState > mcproto.StateStatus {
|
||||||
@@ -273,13 +344,20 @@ func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net.
|
|||||||
WithField("resolvedHost", resolvedHost).
|
WithField("resolvedHost", resolvedHost).
|
||||||
Warn("Unable to find registered backend")
|
Warn("Unable to find registered backend")
|
||||||
c.metrics.Errors.With("type", "missing_backend").Add(1)
|
c.metrics.Errors.With("type", "missing_backend").Add(1)
|
||||||
|
|
||||||
|
if c.connectionNotifier != nil {
|
||||||
|
c.connectionNotifier.NotifyMissingBackend(ctx, clientAddr, serverAddress, userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.
|
logrus.
|
||||||
WithField("client", clientAddr).
|
WithField("client", clientAddr).
|
||||||
WithField("server", serverAddress).
|
WithField("server", serverAddress).
|
||||||
WithField("backendHostPort", backendHostPort).
|
WithField("backendHostPort", backendHostPort).
|
||||||
Info("Connecting to backend")
|
Info("Connecting to backend")
|
||||||
|
|
||||||
backendConn, err := net.Dial("tcp", backendHostPort)
|
backendConn, err := net.Dial("tcp", backendHostPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.
|
logrus.
|
||||||
@@ -289,9 +367,18 @@ func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net.
|
|||||||
WithField("backend", backendHostPort).
|
WithField("backend", backendHostPort).
|
||||||
Warn("Unable to connect to backend")
|
Warn("Unable to connect to backend")
|
||||||
c.metrics.Errors.With("type", "backend_failed").Add(1)
|
c.metrics.Errors.With("type", "backend_failed").Add(1)
|
||||||
|
|
||||||
|
if c.connectionNotifier != nil {
|
||||||
|
c.connectionNotifier.NotifyFailedBackendConnection(ctx, clientAddr, serverAddress, userInfo, backendHostPort, err)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.connectionNotifier != nil {
|
||||||
|
c.connectionNotifier.NotifyConnected(ctx, clientAddr, serverAddress, userInfo, backendHostPort)
|
||||||
|
}
|
||||||
|
|
||||||
c.metrics.ConnectionsBackend.With("host", resolvedHost).Add(1)
|
c.metrics.ConnectionsBackend.With("host", resolvedHost).Add(1)
|
||||||
|
|
||||||
c.metrics.ActiveConnections.Set(float64(
|
c.metrics.ActiveConnections.Set(float64(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user