Skip to content

Commit cc59052

Browse files
authored
Add connection webhook notifications (#392)
Also * Added decode of LoginStart message * Add metrics backend constants * Updated usage section * Documented MaxFrameLength
1 parent a058d6e commit cc59052

File tree

13 files changed

+585
-66
lines changed

13 files changed

+585
-66
lines changed

README.md

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Routes Minecraft client connections to backend servers based upon the requested
4343
-mapping value
4444
Comma or newline delimited or repeated mappings of externalHostname=host:port (env MAPPING)
4545
-metrics-backend string
46-
Backend to use for metrics exposure/publishing: discard,expvar,influxdb (env METRICS_BACKEND) (default "discard")
46+
Backend to use for metrics exposure/publishing: discard,expvar,influxdb,prometheus (env METRICS_BACKEND) (default "discard")
4747
-metrics-backend-config-influxdb-addr string
4848
(env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR)
4949
-metrics-backend-config-influxdb-database string
@@ -74,9 +74,12 @@ Routes Minecraft client connections to backend servers based upon the requested
7474
Send PROXY protocol to backend servers (env USE_PROXY_PROTOCOL)
7575
-version
7676
Output version and exit (env VERSION)
77+
-webhook-require-user
78+
Indicates if the webhook will only be called if a user is connecting rather than just server list/ping (env WEBHOOK_REQUIRE_USER)
79+
-webhook-url string
80+
If set, a POST request that contains connection status notifications will be sent to this HTTP address (env WEBHOOK_URL)
7781
```
7882

79-
8083
## Docker Multi-Architecture Image
8184

8285
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
353356

354357
In the Minecraft client, the server address will be the part after the "tcp://" prefix, such as `8.tcp.ngrok.io:99999`.
355358

359+
## Webhook Support
360+
361+
Refer to [the usage section above](#usage) for `-webhook-*` argument descriptions.
362+
363+
### Sample connect event payloads
364+
365+
The following are sample payloads for the `connect` webhook events.
366+
367+
#### Successful player backend connection
368+
369+
```json
370+
{
371+
"event": "connect",
372+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
373+
"status": "success",
374+
"client": {
375+
"host": "127.0.0.1",
376+
"port": 56860
377+
},
378+
"server": "localhost",
379+
"player": {
380+
"name": "itzg",
381+
"uuid": "5cddfd26-fc86-4981-b52e-c42bb10bfdef"
382+
},
383+
"backend": "localhost:25566"
384+
}
385+
```
386+
387+
**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.
388+
389+
#### Successful server ping backend connection
390+
391+
**NOTE** the absence of `player` in this payload since the Minecraft client does not send player information in the server ping request.
392+
393+
```json
394+
{
395+
"event": "connect",
396+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
397+
"status": "success",
398+
"client": {
399+
"host": "127.0.0.1",
400+
"port": 56396
401+
},
402+
"server": "localhost",
403+
"backend": "localhost:25566"
404+
}
405+
```
406+
407+
#### Missing backend
408+
409+
In this the status is `"missing-backend"` since the requested server `invalid.example.com` does not have a configured/discovered backend entry.
410+
411+
```json
412+
{
413+
"event": "connect",
414+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
415+
"status": "missing-backend",
416+
"client": {
417+
"host": "127.0.0.1",
418+
"port": 56891
419+
},
420+
"server": "invalid.example.com",
421+
"player": {
422+
"name": "itzg",
423+
"uuid": "5cddfd26-fc86-4981-b52e-c42bb10bfdef"
424+
},
425+
"error": "No backend found"
426+
}
427+
```
428+
429+
#### Failed backend connection
430+
431+
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.
432+
433+
```json
434+
{
435+
"event": "connect",
436+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
437+
"status": "failed-backend-connection",
438+
"client": {
439+
"host": "127.0.0.1",
440+
"port": 56905
441+
},
442+
"server": "localhost",
443+
"player": {
444+
"name": "itzg",
445+
"uuid": "5cddfd26-fc86-4981-b52e-c42bb10bfdef"
446+
},
447+
"backend": "localhost:25566",
448+
"error": "dial tcp [::1]:25566: connectex: No connection could be made because the target machine actively refused it."
449+
}
450+
```
451+
356452
## Development
357453

358454
### Building locally with Docker

cmd/mc-router/main.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ type MetricsBackendConfig struct {
2828
}
2929
}
3030

31+
type WebhookConfig struct {
32+
Url string `usage:"If set, a POST request that contains connection status notifications will be sent to this HTTP address"`
33+
RequireUser bool `default:"false" usage:"Indicates if the webhook will only be called if a user is connecting rather than just server list/ping"`
34+
}
35+
3136
type Config struct {
3237
Port int `default:"25565" usage:"The [port] bound to listen for Minecraft client connections"`
3338
Default string `usage:"host:port of a default Minecraft server to use when mapping not found"`
@@ -57,6 +62,8 @@ type Config struct {
5762
ClientsToDeny []string `usage:"Zero or more client IP addresses or CIDRs to deny. Ignored if any configured to allow"`
5863

5964
SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"`
65+
66+
Webhook WebhookConfig `usage:"Webhook configuration"`
6067
}
6168

6269
var (
@@ -135,12 +142,23 @@ func main() {
135142
trustedIpNets = append(trustedIpNets, ipNet)
136143
}
137144

145+
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets)
146+
138147
clientFilter, err := server.NewClientFilter(config.ClientsToAllow, config.ClientsToDeny)
139148
if err != nil {
140149
logrus.WithError(err).Fatal("Unable to create client filter")
141150
}
151+
connector.SetClientFilter(clientFilter)
152+
153+
if config.Webhook.Url != "" {
154+
logrus.
155+
WithField("url", config.Webhook.Url).
156+
WithField("require-user", config.Webhook.RequireUser).
157+
Info("Using webhook for connection status notifications")
158+
connector.SetConnectionNotifier(
159+
server.NewWebhookNotifier(config.Webhook.Url, config.Webhook.RequireUser))
160+
}
142161

143-
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets, clientFilter)
144162
if config.NgrokToken != "" {
145163
connector.UseNgrok(config.NgrokToken)
146164
}

cmd/mc-router/metrics.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,26 @@ type MetricsBuilder interface {
2424
Start(ctx context.Context) error
2525
}
2626

27+
const (
28+
MetricsBackendExpvar = "expvar"
29+
MetricsBackendPrometheus = "prometheus"
30+
MetricsBackendInfluxDB = "influxdb"
31+
MetricsBackendDiscard = "discard"
32+
)
33+
34+
// NewMetricsBuilder creates a new MetricsBuilder based on the specified backend.
35+
// If the backend is not recognized, a discard builder is returned.
36+
// config can be nil if the backend is not influxdb.
2737
func NewMetricsBuilder(backend string, config *MetricsBackendConfig) MetricsBuilder {
2838
switch strings.ToLower(backend) {
29-
case "expvar":
39+
case MetricsBackendExpvar:
3040
return &expvarMetricsBuilder{}
31-
case "prometheus":
41+
case MetricsBackendPrometheus:
3242
return &prometheusMetricsBuilder{}
33-
case "influxdb":
43+
case MetricsBackendInfluxDB:
3444
return &influxMetricsBuilder{config: config}
45+
case MetricsBackendDiscard:
46+
return &discardMetricsBuilder{}
3547
default:
3648
return &discardMetricsBuilder{}
3749
}

mcproto/decode.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package mcproto
2+
3+
import (
4+
"bytes"
5+
"github.com/pkg/errors"
6+
)
7+
8+
const invalidPacketDataBytesMsg = "data should be byte slice from Packet.Data"
9+
10+
// DecodeHandshake takes the Packet.Data bytes and decodes a Handshake message from it
11+
func DecodeHandshake(data interface{}) (*Handshake, error) {
12+
13+
dataBytes, ok := data.([]byte)
14+
if !ok {
15+
return nil, errors.New(invalidPacketDataBytesMsg)
16+
}
17+
18+
handshake := &Handshake{}
19+
buffer := bytes.NewBuffer(dataBytes)
20+
var err error
21+
22+
handshake.ProtocolVersion, err = ReadVarInt(buffer)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
handshake.ServerAddress, err = ReadString(buffer)
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
handshake.ServerPort, err = ReadUnsignedShort(buffer)
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
nextState, err := ReadVarInt(buffer)
38+
if err != nil {
39+
return nil, err
40+
}
41+
handshake.NextState = State(nextState)
42+
return handshake, nil
43+
}
44+
45+
// DecodeLoginStart takes the Packet.Data bytes and decodes a LoginStart message from it
46+
func DecodeLoginStart(data interface{}) (*LoginStart, error) {
47+
dataBytes, ok := data.([]byte)
48+
if !ok {
49+
return nil, errors.New(invalidPacketDataBytesMsg)
50+
}
51+
52+
loginStart := &LoginStart{}
53+
buffer := bytes.NewBuffer(dataBytes)
54+
var err error
55+
56+
loginStart.Name, err = ReadString(buffer)
57+
if err != nil {
58+
return nil, errors.Wrap(err, "failed to read username")
59+
}
60+
61+
loginStart.PlayerUuid, err = ReadUuid(buffer)
62+
if err != nil {
63+
return nil, errors.Wrap(err, "failed to read player uuid")
64+
}
65+
66+
return loginStart, nil
67+
}

mcproto/handshake-login-start.hex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
10008206096c6f63616c686f737463dd02
2+
16000469747a675cddfd26fc864981b52ec42bb10bfdef

mcproto/handshake-status.hex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
10008206096c6f63616c686f737463dd01
2+
0100

mcproto/read.go

Lines changed: 18 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"encoding/binary"
7+
"github.com/google/uuid"
78
"io"
89
"net"
910
"strings"
@@ -15,22 +16,27 @@ import (
1516
"golang.org/x/text/transform"
1617
)
1718

18-
func ReadPacket(reader io.Reader, addr net.Addr, state State) (*Packet, error) {
19+
// MaxFrameLength is declared at https://minecraft.wiki/w/Java_Edition_protocol#Packet_format
20+
// to be 2^21 - 1
21+
const MaxFrameLength = 2097151
22+
23+
// ReadPacket reads a packet from the given reader based on the provided connection state.
24+
// Returns a pointer to the Packet and an error if reading fails.
25+
// Handles legacy server list ping packet when in the handshaking state.
26+
// The provided addr is used for logging purposes.
27+
func ReadPacket(reader *bufio.Reader, addr net.Addr, state State) (*Packet, error) {
1928
logrus.
2029
WithField("client", addr).
2130
Debug("Reading packet")
2231

2332
if state == StateHandshaking {
24-
bufReader := bufio.NewReader(reader)
25-
data, err := bufReader.Peek(1)
33+
data, err := reader.Peek(1)
2634
if err != nil {
2735
return nil, err
2836
}
2937

3038
if data[0] == PacketIdLegacyServerListPing {
31-
return ReadLegacyServerListPing(bufReader, addr)
32-
} else {
33-
reader = bufReader
39+
return ReadLegacyServerListPing(reader, addr)
3440
}
3541
}
3642

@@ -161,8 +167,7 @@ func ReadFrame(reader io.Reader, addr net.Addr) (*Frame, error) {
161167
return nil, err
162168
}
163169

164-
// Limit frame length to 2^21 - 1
165-
if frame.Length > 2097151 {
170+
if frame.Length > MaxFrameLength {
166171
return nil, errors.Errorf("frame length %d too large", frame.Length)
167172
}
168173

@@ -280,36 +285,11 @@ func ReadUnsignedInt(reader io.Reader) (uint32, error) {
280285
return value, nil
281286
}
282287

283-
func ReadHandshake(data interface{}) (*Handshake, error) {
284-
285-
dataBytes, ok := data.([]byte)
286-
if !ok {
287-
return nil, errors.New("data is not expected byte slice")
288-
}
289-
290-
handshake := &Handshake{}
291-
buffer := bytes.NewBuffer(dataBytes)
292-
var err error
293-
294-
handshake.ProtocolVersion, err = ReadVarInt(buffer)
295-
if err != nil {
296-
return nil, err
297-
}
298-
299-
handshake.ServerAddress, err = ReadString(buffer)
288+
func ReadUuid(reader io.Reader) (uuid.UUID, error) {
289+
uuidBytes := make([]byte, 16)
290+
_, err := io.ReadFull(reader, uuidBytes)
300291
if err != nil {
301-
return nil, err
302-
}
303-
304-
handshake.ServerPort, err = ReadUnsignedShort(buffer)
305-
if err != nil {
306-
return nil, err
307-
}
308-
309-
nextState, err := ReadVarInt(buffer)
310-
if err != nil {
311-
return nil, err
292+
return uuid.UUID{}, err
312293
}
313-
handshake.NextState = nextState
314-
return handshake, nil
294+
return uuid.FromBytes(uuidBytes)
315295
}

0 commit comments

Comments
 (0)