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://:/ // // 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() }