CS2 tags a command's output with the id of the empty end packet when both arrive together, and replies to the end packet with a bare \x00\x01. The client dropped the output as stale, so the panel reported WebPanelBridge as missing. Also log each new kind of poll failure once, and recovery.
243 lines
6.2 KiB
Go
243 lines
6.2 KiB
Go
// Package rcon is a Source RCON client (the TCP protocol CS2 still speaks).
|
|
//
|
|
// One connection is kept open and shared; commands are serialised with a mutex. A response can
|
|
// span several packets, so every command is followed by an empty SERVERDATA_RESPONSE_VALUE packet:
|
|
// the server answers packets in order, so its reply to that packet means the command's output is
|
|
// complete. If the server never replies to it, reading stops after a short idle timeout instead.
|
|
//
|
|
// CS2 differs from older Source servers in two ways this handles: when both packets arrive
|
|
// together it tags the command's output with the end packet's id, and it answers the end packet
|
|
// with a single "\x00\x01" body rather than an empty packet followed by that one.
|
|
package rcon
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
typeResponseValue = 0
|
|
typeExecCommand = 2
|
|
typeAuthResponse = 2
|
|
typeAuth = 3
|
|
|
|
maxPacket = 4096 + 14
|
|
|
|
// The body a server sends in reply to an empty SERVERDATA_RESPONSE_VALUE packet (0x00 0x01
|
|
// 0x00 0x00, less the trailing nulls read() trims).
|
|
endMarker = "\x00\x01"
|
|
)
|
|
|
|
// ErrAuth means the server rejected the RCON password.
|
|
var ErrAuth = errors.New("rcon: wrong password")
|
|
|
|
// ErrDown means a recent attempt to reach the server failed, so this one wasn't tried.
|
|
var ErrDown = errors.New("rcon: server unreachable, retrying shortly")
|
|
|
|
// backoff is how long Exec fails fast after the server couldn't be reached, so page loads and
|
|
// actions don't each wait out a connection timeout while the game server is down.
|
|
const backoff = 10 * time.Second
|
|
|
|
type Client struct {
|
|
addr string
|
|
password string
|
|
timeout time.Duration
|
|
|
|
mu sync.Mutex
|
|
conn net.Conn
|
|
nextID int32
|
|
downUntil time.Time
|
|
lastErr error
|
|
}
|
|
|
|
func New(addr, password string) *Client {
|
|
return &Client{addr: addr, password: password, timeout: 5 * time.Second, nextID: 1}
|
|
}
|
|
|
|
// Exec runs a console command and returns everything it printed.
|
|
func (c *Client) Exec(cmd string) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if time.Now().Before(c.downUntil) {
|
|
return "", fmt.Errorf("%w: %v", ErrDown, c.lastErr)
|
|
}
|
|
hadConn := c.conn != nil
|
|
out, err := c.exec(cmd)
|
|
if err != nil && hadConn && !errors.Is(err, ErrAuth) {
|
|
// The kept-open connection may have gone stale (server restart, idle timeout): retry once
|
|
// on a fresh one. A failed fresh connection isn't retried.
|
|
c.close()
|
|
out, err = c.exec(cmd)
|
|
}
|
|
if err != nil {
|
|
c.close()
|
|
c.downUntil, c.lastErr = time.Now().Add(backoff), err
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) exec(cmd string) (string, error) {
|
|
if c.conn == nil {
|
|
if err := c.connect(); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
id := c.id()
|
|
endID := c.id()
|
|
if err := c.write(id, typeExecCommand, cmd); err != nil {
|
|
return "", err
|
|
}
|
|
if err := c.write(endID, typeResponseValue, ""); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var out strings.Builder
|
|
deadline := time.Now().Add(c.timeout)
|
|
idle := 400 * time.Millisecond
|
|
got := false
|
|
for {
|
|
wait := deadline
|
|
if got {
|
|
// Once output has started, stop after a quiet gap even without the end marker.
|
|
if d := time.Now().Add(idle); d.Before(wait) {
|
|
wait = d
|
|
}
|
|
}
|
|
_ = c.conn.SetReadDeadline(wait)
|
|
pid, _, body, err := c.read()
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() && got {
|
|
return out.String(), nil
|
|
}
|
|
return "", err
|
|
}
|
|
if pid != id && pid != endID {
|
|
continue // left over from an earlier command
|
|
}
|
|
switch {
|
|
case pid == endID && body == endMarker:
|
|
return out.String(), nil
|
|
case pid == endID && body == "":
|
|
// Older servers echo the empty packet, then send endMarker. Drain it.
|
|
_ = c.conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
|
|
_, _, _, _ = c.read()
|
|
return out.String(), nil
|
|
default:
|
|
out.WriteString(body)
|
|
got = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) connect() error {
|
|
conn, err := net.DialTimeout("tcp", c.addr, 2*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("rcon: connect %s: %w", c.addr, err)
|
|
}
|
|
c.conn = conn
|
|
id := c.id()
|
|
if err := c.write(id, typeAuth, c.password); err != nil {
|
|
c.close()
|
|
return err
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(c.timeout))
|
|
for {
|
|
pid, ptype, _, err := c.read()
|
|
if err != nil {
|
|
c.close()
|
|
return fmt.Errorf("rcon: auth: %w", err)
|
|
}
|
|
if ptype != typeAuthResponse {
|
|
continue // an empty RESPONSE_VALUE precedes the auth response
|
|
}
|
|
if pid == -1 {
|
|
c.close()
|
|
return ErrAuth
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *Client) id() int32 {
|
|
c.nextID++
|
|
if c.nextID > 1<<30 {
|
|
c.nextID = 1
|
|
}
|
|
return c.nextID
|
|
}
|
|
|
|
func (c *Client) write(id int32, ptype int32, body string) error {
|
|
var buf bytes.Buffer
|
|
size := int32(len(body) + 10)
|
|
_ = binary.Write(&buf, binary.LittleEndian, size)
|
|
_ = binary.Write(&buf, binary.LittleEndian, id)
|
|
_ = binary.Write(&buf, binary.LittleEndian, ptype)
|
|
buf.WriteString(body)
|
|
buf.Write([]byte{0, 0})
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(c.timeout))
|
|
_, err := c.conn.Write(buf.Bytes())
|
|
return err
|
|
}
|
|
|
|
func (c *Client) read() (id int32, ptype int32, body string, err error) {
|
|
var size int32
|
|
if err = binary.Read(c.conn, binary.LittleEndian, &size); err != nil {
|
|
return
|
|
}
|
|
if size < 10 || size > maxPacket {
|
|
err = fmt.Errorf("rcon: bad packet size %d", size)
|
|
return
|
|
}
|
|
data := make([]byte, size)
|
|
if _, err = io.ReadFull(c.conn, data); err != nil {
|
|
return
|
|
}
|
|
id = int32(binary.LittleEndian.Uint32(data[0:4]))
|
|
ptype = int32(binary.LittleEndian.Uint32(data[4:8]))
|
|
body = string(bytes.TrimRight(data[8:], "\x00"))
|
|
return
|
|
}
|
|
|
|
func (c *Client) close() {
|
|
if c.conn != nil {
|
|
_ = c.conn.Close()
|
|
c.conn = nil
|
|
}
|
|
}
|
|
|
|
// Close drops the connection. The next Exec reconnects.
|
|
func (c *Client) Close() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.close()
|
|
}
|
|
|
|
// Arg makes s safe to pass as one quoted console argument: the console splits commands on ';' and
|
|
// newlines and has no escape for '"', so those are removed along with other control characters.
|
|
func Arg(s string, max int) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '"' || r == ';' || r == '\\':
|
|
continue
|
|
case r < 0x20 || r == 0x7f:
|
|
b.WriteRune(' ')
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
out := strings.TrimSpace(b.String())
|
|
if max > 0 && len([]rune(out)) > max {
|
|
out = string([]rune(out)[:max])
|
|
}
|
|
return out
|
|
}
|