0f6aea796c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
5.4 KiB
Go
180 lines
5.4 KiB
Go
package rcon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/net/websocket"
|
|
)
|
|
|
|
// WebSocketDialer speaks Facepunch's WebSocket RCON — the protocol used
|
|
// by Rust (app 258550) since they removed Source RCON years ago. A small
|
|
// subset of games have adopted the same pattern (a few Oxide-compatible
|
|
// community servers, some Unity-based survival games).
|
|
//
|
|
// Wire protocol (JSON text frames over a plain websocket):
|
|
//
|
|
// client → server: {"Identifier": N, "Message": "command", "Name": "panel"}
|
|
// server → client: {"Identifier": N, "Message": "result text",
|
|
// "Type": "Generic", "Stacktrace": ""}
|
|
//
|
|
// The password is passed as the URL path, not a header:
|
|
//
|
|
// ws://<host>:<port>/<password>
|
|
//
|
|
// Connections are long-lived — Rust's RCON doesn't close after each command
|
|
// the way Conan's does — but we still redial transparently on EOF/reset
|
|
// via the tracker's existing logic.
|
|
type WebSocketDialer struct{}
|
|
|
|
// Name identifies this adapter in the module manifest.
|
|
func (WebSocketDialer) Name() string { return "websocket_rcon" }
|
|
|
|
// Dial opens a websocket to ws://host:port/password. Returns a client that
|
|
// serializes Exec calls; Rust sometimes interleaves async log spam into the
|
|
// same connection as command replies, so we match responses back by id.
|
|
func (WebSocketDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
|
|
if opts.Host == "" || opts.Port == 0 {
|
|
return nil, errors.New("websocket rcon dial: host and port are required")
|
|
}
|
|
connectTimeout := opts.ConnectTimeout
|
|
if connectTimeout == 0 {
|
|
connectTimeout = 5 * time.Second
|
|
}
|
|
|
|
wsURL := fmt.Sprintf("ws://%s:%d/%s", opts.Host, opts.Port, url.PathEscape(opts.Password))
|
|
origin := fmt.Sprintf("http://%s:%d/", opts.Host, opts.Port)
|
|
cfg, err := websocket.NewConfig(wsURL, origin)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("websocket rcon: build config: %w", err)
|
|
}
|
|
|
|
// x/net/websocket.Dial doesn't honor a context; run it in a goroutine
|
|
// so we can bail out when ctx.Done fires. Rust's authentication is
|
|
// implicit — bad password = server closes the handshake with a 401.
|
|
type dialResult struct {
|
|
ws *websocket.Conn
|
|
err error
|
|
}
|
|
ch := make(chan dialResult, 1)
|
|
go func() {
|
|
ws, err := websocket.DialConfig(cfg)
|
|
ch <- dialResult{ws, err}
|
|
}()
|
|
select {
|
|
case r := <-ch:
|
|
if r.err != nil {
|
|
return nil, fmt.Errorf("websocket rcon dial %s:%d: %w", opts.Host, opts.Port, r.err)
|
|
}
|
|
return newWebSocketClient(r.ws), nil
|
|
case <-time.After(connectTimeout):
|
|
return nil, fmt.Errorf("websocket rcon dial %s:%d: connect timeout", opts.Host, opts.Port)
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
type wsRequest struct {
|
|
Identifier int32 `json:"Identifier"`
|
|
Message string `json:"Message"`
|
|
Name string `json:"Name"`
|
|
}
|
|
|
|
type wsResponse struct {
|
|
Message string `json:"Message"`
|
|
Identifier int32 `json:"Identifier"`
|
|
Type string `json:"Type"`
|
|
Stacktrace string `json:"Stacktrace"`
|
|
}
|
|
|
|
type wsClient struct {
|
|
ws *websocket.Conn
|
|
nextID atomic.Int32
|
|
execMu sync.Mutex // serializes Exec calls
|
|
readMu sync.Mutex // guards ws reads; Close may race
|
|
closed atomic.Bool
|
|
unsolic chan wsResponse // buffers server-pushed log events we don't consume
|
|
}
|
|
|
|
func newWebSocketClient(ws *websocket.Conn) *wsClient {
|
|
c := &wsClient{ws: ws, unsolic: make(chan wsResponse, 64)}
|
|
c.nextID.Store(1)
|
|
return c
|
|
}
|
|
|
|
// Exec runs one command and returns the matching response body. Rust
|
|
// echoes our request's Identifier back, so we can reject unrelated log
|
|
// messages the server pushes on the same connection.
|
|
func (c *wsClient) Exec(ctx context.Context, cmd string) (string, error) {
|
|
if c.closed.Load() {
|
|
return "", errors.New("websocket rcon: client closed")
|
|
}
|
|
c.execMu.Lock()
|
|
defer c.execMu.Unlock()
|
|
|
|
id := c.nextID.Add(1)
|
|
req := wsRequest{Identifier: id, Message: cmd, Name: "panel"}
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal: %w", err)
|
|
}
|
|
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
deadline = time.Now().Add(10 * time.Second)
|
|
}
|
|
if err := c.ws.SetDeadline(deadline); err != nil {
|
|
return "", fmt.Errorf("set deadline: %w", err)
|
|
}
|
|
defer c.ws.SetDeadline(time.Time{}) //nolint:errcheck
|
|
|
|
if err := websocket.Message.Send(c.ws, string(payload)); err != nil {
|
|
return "", fmt.Errorf("send: %w", err)
|
|
}
|
|
|
|
// Read frames until one matches our Identifier. Rust pipes chat, log,
|
|
// and player-event messages through the same socket; if their id is
|
|
// 0 (or doesn't match ours) we drop them into unsolic for someone
|
|
// else to consume. 200ms allowance for interleaved frames.
|
|
for {
|
|
c.readMu.Lock()
|
|
var raw string
|
|
rerr := websocket.Message.Receive(c.ws, &raw)
|
|
c.readMu.Unlock()
|
|
if rerr != nil {
|
|
return "", fmt.Errorf("recv: %w", rerr)
|
|
}
|
|
var resp wsResponse
|
|
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
|
|
// Non-JSON frame — Rust shouldn't send those but be forgiving.
|
|
continue
|
|
}
|
|
if resp.Identifier != id {
|
|
// Async server push (log line, chat, etc.). Buffer non-blockingly.
|
|
select {
|
|
case c.unsolic <- resp:
|
|
default:
|
|
}
|
|
continue
|
|
}
|
|
if resp.Stacktrace != "" {
|
|
return resp.Message, fmt.Errorf("rust rcon error: %s", resp.Stacktrace)
|
|
}
|
|
return resp.Message, nil
|
|
}
|
|
}
|
|
|
|
// Close tears down the underlying websocket connection.
|
|
func (c *wsClient) Close() error {
|
|
if !c.closed.CompareAndSwap(false, true) {
|
|
return nil
|
|
}
|
|
return c.ws.Close()
|
|
}
|