Add vanilla parity harness and harden server boundaries
This commit is contained in:
parent
1924cb5591
commit
ca019756ec
25 changed files with 1118 additions and 217 deletions
67
internal/network/boundary_test.go
Normal file
67
internal/network/boundary_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
func TestValidPlayerName(t *testing.T) {
|
||||
for _, name := range []string{"Steve", "player_123", "A"} {
|
||||
if !validPlayerName(name) {
|
||||
t.Errorf("validPlayerName(%q) = false", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"", "seventeen_chars_1", "player-name", "имя"} {
|
||||
if validPlayerName(name) {
|
||||
t.Errorf("validPlayerName(%q) = true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerMoveRejectsCoordinatesOutsideWorld(t *testing.T) {
|
||||
cfg := server.DefaultConfig()
|
||||
cfg.WorldDir = ""
|
||||
srv, err := server.NewWithCache(cfg, world.NewCache(-1, func(cx, cz int32) *world.Chunk {
|
||||
return world.GenerateFlat(cx, cz)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := srv.RegisterPlayer(server.Profile{Name: "Steve"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{srv: srv, session: session, log: slog.Default()}
|
||||
for _, position := range [][3]float64{
|
||||
{maxPlayerXZ + 1, 0, 0},
|
||||
{0, maxPlayerY + 1, 0},
|
||||
{0, 0, -maxPlayerXZ - 1},
|
||||
{math.NaN(), 0, 0},
|
||||
} {
|
||||
if err := h.onPlayerMove(position[0], position[1], position[2], 0, 0, true); err == nil {
|
||||
t.Errorf("accepted position %v", position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepAliveRequiresPendingMatchingID(t *testing.T) {
|
||||
h := &handler{log: slog.Default()}
|
||||
w := protocol.NewWriter(8).Int64(42)
|
||||
pkt := protocol.Packet{ID: protocol.PlayKeepAliveServer, Data: w.Bytes()}
|
||||
if err := h.handlePlay(pkt); err == nil {
|
||||
t.Fatal("accepted keep-alive response without a pending challenge")
|
||||
}
|
||||
h.keepAlivePending = true
|
||||
h.keepAliveID = 42
|
||||
if err := h.handlePlay(pkt); err != nil {
|
||||
t.Fatalf("matching response: %v", err)
|
||||
}
|
||||
if h.keepAlivePending {
|
||||
t.Fatal("matching response did not clear pending challenge")
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ package network
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -12,6 +13,8 @@ import (
|
|||
"regionio/internal/server"
|
||||
)
|
||||
|
||||
const networkWriteTimeout = 30 * time.Second
|
||||
|
||||
// Conn wraps a TCP connection with buffered reads and tracks protocol state.
|
||||
type Conn struct {
|
||||
raw net.Conn
|
||||
|
|
@ -70,6 +73,10 @@ func (c *Conn) ReadPacket() (protocol.Packet, error) {
|
|||
func (c *Conn) Send(id int32, body []byte) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.raw.SetWriteDeadline(time.Time{})
|
||||
return protocol.WritePacket(c.raw, c.compressionThreshold, id, body)
|
||||
}
|
||||
|
||||
|
|
@ -84,8 +91,21 @@ func (c *Conn) SendWriter(id int32, w *protocol.Writer) error {
|
|||
func (c *Conn) SendFramed(frame []byte) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
_, err := c.raw.Write(frame)
|
||||
return err
|
||||
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.raw.SetWriteDeadline(time.Time{})
|
||||
for len(frame) > 0 {
|
||||
n, err := c.raw.Write(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 0 || n > len(frame) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
frame = frame[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompressionThreshold returns the active threshold (-1 if disabled).
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
|
|
@ -27,11 +29,17 @@ type handler struct {
|
|||
streamer *streamer
|
||||
// viewDistance is the client's requested view distance (from
|
||||
// client_information), clamped; used to size the streamer.
|
||||
viewDistance int
|
||||
session *server.PlayerSession
|
||||
knownPlayers map[[16]byte]bool
|
||||
knownEntities map[int32]visibleEntity
|
||||
spawnY float64
|
||||
viewDistance int
|
||||
session *server.PlayerSession
|
||||
knownPlayers map[[16]byte]bool
|
||||
knownEntities map[int32]visibleEntity
|
||||
spawnY float64
|
||||
protocolVersion int32
|
||||
|
||||
keepAliveMu sync.Mutex
|
||||
keepAlivePending bool
|
||||
keepAliveID int64
|
||||
keepAliveSent time.Time
|
||||
|
||||
// Creative inventory state for block placement.
|
||||
heldSlot int32 // selected hotbar index (0-8)
|
||||
|
|
@ -111,6 +119,7 @@ func (h *handler) handleHandshake(pkt protocol.Packet) error {
|
|||
|
||||
h.log.Debug("handshake",
|
||||
"protocol", protoVer, "addr", addr, "port", port, "next", next)
|
||||
h.protocolVersion = protoVer
|
||||
|
||||
switch next {
|
||||
case protocol.NextStateStatus:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package network
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
|
|
@ -32,12 +33,15 @@ func (h *handler) handleLogin(pkt protocol.Packet) error {
|
|||
}
|
||||
|
||||
func (h *handler) handleLoginStart(pkt protocol.Packet) error {
|
||||
if h.protocolVersion != protocol.ProtocolVersion {
|
||||
return fmt.Errorf("unsupported protocol %d, want %d", h.protocolVersion, protocol.ProtocolVersion)
|
||||
}
|
||||
r := pkt.Body()
|
||||
name, err := r.String()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "" || len(name) > 16 {
|
||||
if !validPlayerName(name) {
|
||||
return errors.New("invalid login name")
|
||||
}
|
||||
// The client also sends a UUID, but in offline mode we derive our own so it
|
||||
|
|
@ -65,6 +69,18 @@ func (h *handler) handleLoginStart(pkt protocol.Packet) error {
|
|||
return h.sendLoginSuccess()
|
||||
}
|
||||
|
||||
func validPlayerName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 16 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
if r != '_' && (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sendLoginSuccess writes the Login Success packet. For protocol 775 the body
|
||||
// is: UUID, Username, then a VarInt-prefixed array of profile properties (none
|
||||
// in offline mode).
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import (
|
|||
// Spawn column. The feet-level Y is resolved from the generated surface when
|
||||
// the player enters the play phase.
|
||||
const (
|
||||
spawnX = 8.5
|
||||
spawnZ = 8.5
|
||||
spawnX = 8.5
|
||||
spawnZ = 8.5
|
||||
maxPlayerXZ = 30_000_000.0
|
||||
maxPlayerY = 20_000_000.0
|
||||
)
|
||||
|
||||
// beginPlay sends the join sequence once the client enters the Play phase and
|
||||
|
|
@ -107,6 +109,9 @@ func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround boo
|
|||
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
|
||||
return errors.New("invalid player position")
|
||||
}
|
||||
if math.Abs(x) > maxPlayerXZ || math.Abs(z) > maxPlayerXZ || math.Abs(y) > maxPlayerY {
|
||||
return errors.New("player position outside world bounds")
|
||||
}
|
||||
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
|
||||
cx := int32(int64(math.Floor(x)) >> 4)
|
||||
cz := int32(int64(math.Floor(z)) >> 4)
|
||||
|
|
@ -203,7 +208,21 @@ func (h *handler) keepAliveLoop() {
|
|||
case <-h.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.keepAliveMu.Lock()
|
||||
if h.keepAlivePending {
|
||||
timedOut := time.Since(h.keepAliveSent) >= 30*time.Second
|
||||
h.keepAliveMu.Unlock()
|
||||
if timedOut {
|
||||
_ = h.conn.Close()
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
id := time.Now().UnixMilli()
|
||||
h.keepAlivePending = true
|
||||
h.keepAliveID = id
|
||||
h.keepAliveSent = time.Now()
|
||||
h.keepAliveMu.Unlock()
|
||||
w := protocol.NewWriter(8)
|
||||
w.Int64(id)
|
||||
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
||||
|
|
@ -434,8 +453,20 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
|
|||
return nil
|
||||
|
||||
case protocol.PlayKeepAliveServer:
|
||||
// A response to our keep-alive; presence is enough for liveness.
|
||||
h.log.Debug("keep-alive ack")
|
||||
id, err := pkt.Body().Int64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.keepAliveMu.Lock()
|
||||
valid := h.keepAlivePending && id == h.keepAliveID
|
||||
if valid {
|
||||
h.keepAlivePending = false
|
||||
}
|
||||
h.keepAliveMu.Unlock()
|
||||
if !valid {
|
||||
return errors.New("unexpected keep-alive response")
|
||||
}
|
||||
h.log.Debug("keep-alive ack", "id", id)
|
||||
return nil
|
||||
|
||||
case protocol.PlayPlayerLoaded:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue