Skip to content

Commit 86ab286

Browse files
committed
Implement Classic protocol support
1 parent 66f18b9 commit 86ab286

File tree

5 files changed

+215
-0
lines changed

5 files changed

+215
-0
lines changed

common.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,3 +978,49 @@ func (app *App) BaseRelativePath(path_ string) (string, error) {
978978
baseRelative := strings.TrimPrefix(path_, app.BasePath)
979979
return strings.TrimSuffix(baseRelative, "/"), nil
980980
}
981+
982+
// Remove servers who haven't pinged for a while from the LRU
983+
func (app *App) cleanupHeartbeatLRU() {
984+
now := time.Now()
985+
for {
986+
heartbeatLruMutex.Lock()
987+
back := heartbeatLruList.Back()
988+
if back == nil {
989+
heartbeatLruMutex.Unlock()
990+
break
991+
}
992+
key := back.Value.(ServerKey)
993+
994+
heartbeatSaltMapMutex.RLock()
995+
entry, ok := heartbeatSaltMap[key]
996+
heartbeatSaltMapMutex.RUnlock()
997+
998+
if !ok {
999+
heartbeatLruList.Remove(back)
1000+
heartbeatLruMutex.Unlock()
1001+
continue
1002+
}
1003+
1004+
if now.Sub(entry.Timestamp) > heartbeatLruTTL {
1005+
heartbeatSaltMapMutex.Lock()
1006+
delete(heartbeatSaltMap, key)
1007+
heartbeatSaltMapMutex.Unlock()
1008+
heartbeatLruList.Remove(back)
1009+
heartbeatLruMutex.Unlock()
1010+
} else {
1011+
heartbeatLruMutex.Unlock()
1012+
break
1013+
}
1014+
}
1015+
}
1016+
1017+
func (app *App) RunPeriodicTasks() {
1018+
go func() {
1019+
ticker := time.NewTicker(10 * time.Minute) // repeat every 10 minutes
1020+
defer ticker.Stop()
1021+
1022+
for range ticker.C {
1023+
app.cleanupHeartbeatLRU()
1024+
}
1025+
}()
1026+
}

config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ type BaseConfig struct {
145145
MinPasswordLength int
146146
PlayerUUIDGeneration string
147147
PreMigrationBackups bool
148+
PublicIP string
148149
RateLimit rateLimitConfig
149150
RegistrationExistingPlayer registrationExistingPlayerConfig
150151
RegistrationNewPlayer registrationNewPlayerConfig
@@ -224,6 +225,7 @@ func DefaultRawConfig() RawConfig {
224225
OfflineSkins: true,
225226
PlayerUUIDGeneration: "random",
226227
PreMigrationBackups: true,
228+
PublicIP: "",
227229
RateLimit: defaultRateLimitConfig,
228230
RegistrationExistingPlayer: registrationExistingPlayerConfig{
229231
Allow: false,

doc/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,4 @@ Other available options:
129129
- `ValidPlayerNameRegex`: Regular expression (regex) that player names must match. Currently, Drasl usernames are validated using this regex too. Player names will be limited to a maximum of 16 characters no matter what. Mojang allows the characters `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_`, and by default, Drasl follows suit. Minecraft servers may misbehave if additional characters are allowed. Change to `.+` if you want to allow any player name (that is 16 characters or shorter). String. Default value: `^[a-zA-Z0-9_]+$`.
130130
- `CORSAllowOrigins`: List of origins that may access Drasl API routes. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. Necessary for allowing browsers to access the Drasl API. Set to `["*"]` to allow all origins. Array of strings. Example value: `["https://front-end.example.com"]`. Default value: `[]`.
131131
- `PlayerUUIDGeneration`: How to generate UUIDs for new players. Must be either `"random"` or `"offline"`. `"random"` generates a new random Version 4 UUID. `"offline"` means the player's UUID will be generated from the player's name using the same algorithm Minecraft uses to derive player UUIDs on `online-mode=false` servers. `PlayerUUIDGeneration = "offline"` is useful for migrating `online-mode=false` servers to Drasl since it lets player UUIDs (and thus inventories, permissions, etc.) remain the same when switching from `online-mode=false` to `online-mode=true`. Note: if a player's name is changed, their UUID will not change, even with `PlayerUUIDGeneration = "offline"`. String. Default value: `"random"`.
132+
- `PublicIP`: IPv4 address to assign to [Classic server protocol heartbeat requests](https://minecraft.wiki/w/Classic_server_protocol#Heartbeats) when the originating IP is loopback or within a private IP range. Useful when Drasl runs on the same system as the Minecraft server it communicates with, allowing the heartbeat to report an externally reachable address instead of a local one. If not set, the origin IP will be used and the Classic server may not be joinable. String. Example value: `"1.1.1.1"`.

main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,17 @@ func (app *App) MakeServer() *echo.Echo {
363363
sessionJoinServer := SessionJoinServer(app)
364364
sessionProfile := SessionProfile(app, false)
365365
sessionBlockedServers := SessionBlockedServers(app)
366+
sessionHeartbeat := SessionHeartbeat(app)
367+
sessionGetMpPass := SessionGetMpPass(app)
366368
for _, prefix := range []string{"", "/session", "/authlib-injector/sessionserver"} {
367369
base.GET(prefix+"/session/minecraft/hasJoined", sessionHasJoined)
368370
base.GET(prefix+"/game/checkserver.jsp", sessionCheckServer)
369371
base.POST(prefix+"/session/minecraft/join", sessionJoin)
370372
base.GET(prefix+"/game/joinserver.jsp", sessionJoinServer)
371373
base.GET(prefix+"/session/minecraft/profile/:id", sessionProfile)
372374
base.GET(prefix+"/blockedservers", sessionBlockedServers)
375+
base.Any(prefix+"/heartbeat.jsp", sessionHeartbeat)
376+
base.GET(prefix+"/getMpPass", sessionGetMpPass)
373377
}
374378

375379
// Services
@@ -653,6 +657,8 @@ func (app *App) Run() {
653657
for _, fallbackAPIServer := range PtrSlice(app.FallbackAPIServers) {
654658
go app.PlayerNamesToIDsWorker(fallbackAPIServer)
655659
}
660+
661+
app.RunPeriodicTasks()
656662
}
657663

658664
func main() {

session.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
package main
22

33
import (
4+
"container/list"
5+
"crypto/md5"
6+
"encoding/hex"
47
"errors"
58
"fmt"
69
"github.com/labstack/echo/v4"
710
"github.com/samber/mo"
811
"gorm.io/gorm"
912
"log"
13+
"net"
1014
"net/http"
1115
"net/url"
16+
"strconv"
1217
"strings"
18+
"sync"
19+
"time"
1320
)
1421

1522
type sessionJoinRequest struct {
@@ -18,6 +25,25 @@ type sessionJoinRequest struct {
1825
ServerID string `json:"serverId"`
1926
}
2027

28+
type ServerKey struct {
29+
IP string
30+
Port int
31+
}
32+
33+
type heartbeatSaltEntry struct {
34+
Salt string
35+
Timestamp time.Time
36+
Elem *list.Element
37+
}
38+
39+
var (
40+
heartbeatSaltMap = make(map[ServerKey]heartbeatSaltEntry)
41+
heartbeatSaltMapMutex sync.RWMutex
42+
heartbeatLruList = list.New()
43+
heartbeatLruMutex sync.Mutex
44+
heartbeatLruTTL = 5 * time.Minute // Server should send one every 45 seconds
45+
)
46+
2147
// /session/minecraft/join
2248
// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Protocol_Encryption#Client
2349
func SessionJoin(app *App) func(c echo.Context) error {
@@ -277,3 +303,137 @@ func SessionBlockedServers(app *App) func(c echo.Context) error {
277303
return c.NoContent(http.StatusOK)
278304
}
279305
}
306+
307+
func (app *App) heartbeat(c *echo.Context, ip string, port int, salt string) error {
308+
key := ServerKey{IP: ip, Port: port}
309+
now := time.Now()
310+
311+
heartbeatSaltMapMutex.Lock()
312+
entry, exists := heartbeatSaltMap[key]
313+
if exists {
314+
// Update value by overwriting the map entry
315+
entry.Salt = salt
316+
entry.Timestamp = now
317+
heartbeatSaltMap[key] = entry
318+
} else {
319+
// New entry
320+
entry = heartbeatSaltEntry{
321+
Salt: salt,
322+
Timestamp: now,
323+
}
324+
heartbeatSaltMap[key] = entry
325+
}
326+
327+
heartbeatSaltMapMutex.Unlock()
328+
329+
// Handle heartbeat LRU
330+
heartbeatLruMutex.Lock()
331+
if exists {
332+
heartbeatLruList.MoveToFront(entry.Elem)
333+
} else {
334+
entry.Elem = heartbeatLruList.PushFront(key)
335+
// Save back updated element
336+
heartbeatSaltMapMutex.Lock()
337+
heartbeatSaltMap[key] = entry
338+
heartbeatSaltMapMutex.Unlock()
339+
}
340+
heartbeatLruMutex.Unlock()
341+
342+
return (*c).String(http.StatusOK, fmt.Sprintf("%s:%d", ip, port))
343+
}
344+
345+
// /heartbeat.jsp
346+
// https://minecraft.wiki/w/Classic_server_protocol
347+
// https://www.grahamedgecombe.com/talks/minecraft.pdf
348+
func SessionHeartbeat(app *App) func(c echo.Context) error {
349+
return func(c echo.Context) error {
350+
ip := c.RealIP()
351+
parsed := net.ParseIP(ip)
352+
353+
// Prefer PublicIP config over request IP on private/loopback address ranges
354+
if parsed != nil && (parsed.IsLoopback() || parsed.IsPrivate()) && app.Config.PublicIP != "" {
355+
ip = app.Config.PublicIP
356+
}
357+
358+
// Require port
359+
portStr := c.FormValue("port")
360+
if portStr == "" {
361+
portStr = c.QueryParam("port")
362+
}
363+
if portStr == "" {
364+
return c.String(http.StatusBadRequest, "missing required query parameter: port")
365+
}
366+
port, err := strconv.Atoi(portStr)
367+
if err != nil || port <= 0 || port > 65535 {
368+
return c.String(http.StatusBadRequest, "invalid port value")
369+
}
370+
371+
// Require salt
372+
salt := c.FormValue("salt")
373+
if salt == "" {
374+
salt = c.QueryParam("salt")
375+
}
376+
if salt == "" {
377+
salt = "missingno" // no salt here! are we speaking with c0.0.15a-c0.0.16a?
378+
}
379+
380+
return app.heartbeat(&c, ip, port, salt)
381+
}
382+
}
383+
384+
func (app *App) getMpPass(c *echo.Context, playerName string, ip string, port int) error {
385+
key := ServerKey{IP: ip, Port: port}
386+
387+
heartbeatSaltMapMutex.RLock()
388+
entry, ok := heartbeatSaltMap[key]
389+
heartbeatSaltMapMutex.RUnlock()
390+
391+
if !ok {
392+
return (*c).NoContent(http.StatusNotFound)
393+
}
394+
395+
hash := md5.Sum([]byte(entry.Salt + playerName))
396+
return (*c).String(http.StatusOK, hex.EncodeToString(hash[:]))
397+
}
398+
399+
func SessionGetMpPass(app *App) func(c echo.Context) error {
400+
return withBearerAuthentication(app, func(c echo.Context, user *User, _ *Player) error {
401+
// Get IP from query param
402+
ip := c.QueryParam("ip")
403+
if ip == "" {
404+
return c.String(http.StatusBadRequest, "missing required query parameter: ip")
405+
}
406+
407+
// Get port (optional, default 25565)
408+
port := 25565
409+
portStr := c.QueryParam("port")
410+
if portStr != "" {
411+
p, err := strconv.Atoi(portStr)
412+
if err != nil {
413+
return c.String(http.StatusBadRequest, "invalid port value")
414+
}
415+
port = p
416+
}
417+
418+
// Get player name
419+
playerName := c.QueryParam("player")
420+
if playerName == "" {
421+
return c.String(http.StatusBadRequest, "missing required query parameter: player")
422+
}
423+
424+
// Check if user owns the requested player
425+
found := false
426+
for _, currentPlayer := range user.Players {
427+
if currentPlayer.Name == playerName {
428+
found = true
429+
break
430+
}
431+
}
432+
433+
if !found {
434+
return c.NoContent(http.StatusUnauthorized)
435+
}
436+
437+
return app.getMpPass(&c, playerName, ip, port)
438+
})
439+
}

0 commit comments

Comments
 (0)