package rcon import ( "bufio" "context" "encoding/binary" "errors" "fmt" "io" "net" "strings" "sync" "time" ) // SourceDialer speaks Valve's Source RCON protocol — the standard RCON of // Minecraft, CS2, Rust, Valheim-with-plugins, and most Source-engine games. // // Protocol summary (little-endian throughout): // // int32 size = bytes that follow (i.e. frame length − 4) // int32 id = client-chosen, server echoes // int32 type = 3 auth-request, 2 exec, 0 response, 2 auth-response // body ASCIIZ = command string or response body, null-terminated // byte trailing null = required empty string sentinel // // size = 4 (id) + 4 (type) + len(body) + 1 (body NUL) + 1 (sentinel NUL) type SourceDialer struct{} // Name identifies this adapter in the module manifest. func (SourceDialer) Name() string { return "source_rcon" } const ( pktAuth int32 = 3 pktExecCommand int32 = 2 pktResponseValue int32 = 0 pktAuthResponse int32 = 2 minPktSize = 10 maxPktSize = 4110 ) func (SourceDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) { if opts.Host == "" || opts.Port == 0 { return nil, errors.New("source rcon dial: host and port are required") } timeout := opts.ConnectTimeout if timeout == 0 { timeout = 5 * time.Second } d := &net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", opts.Host, opts.Port)) if err != nil { return nil, fmt.Errorf("source rcon dial %s:%d: %w", opts.Host, opts.Port, err) } c := &sourceClient{conn: conn, reader: bufio.NewReader(conn), nextID: 1} if err := c.authenticate(opts.Password); err != nil { _ = conn.Close() return nil, fmt.Errorf("source rcon auth: %w", err) } return c, nil } type sourceClient struct { conn net.Conn reader *bufio.Reader mu sync.Mutex nextID int32 } // authenticate sends SERVERDATA_AUTH and waits for SERVERDATA_AUTH_RESPONSE. // Valve's spec says servers send an empty RESPONSE_VALUE first (id echoed), // then the AUTH_RESPONSE (id = original on success, −1 on failure). Some // implementations skip the probe — we accept either ordering. func (c *sourceClient) authenticate(password string) error { id := c.assignID() if err := c.writePacket(id, pktAuth, password); err != nil { return err } // ASA's RCON handler is slow to answer AUTH during the first few // minutes after "Server has completed startup" — sometimes 3-4s per // reply when the server is still loading tribe data / saving a // snapshot. The previous 2s per-read deadline + 5s overall was // tripping the tracker into its 32s exponential backoff on every // auth, stranding the panel with "RCON not yet connected" for 5-10 // min even though the server was otherwise fine. Give AUTH a 12s // outer window and 8s per-read — enough slack for ASA's slowest // responses without letting a truly dead connection hang forever. deadline := time.Now().Add(12 * time.Second) for time.Now().Before(deadline) { _ = c.conn.SetReadDeadline(time.Now().Add(8 * time.Second)) gotID, gotType, _, err := c.readPacket() if err != nil { return err } if gotType == pktAuthResponse { // Spec: id == -1 means auth failed (bad password). // Any other id means success. Most servers echo our request id // but some (Conan Exiles, Palworld, a few others) return 0 or // an unrelated id — treat that as success, don't reject. if gotID == -1 { return errors.New("invalid password") } return nil } // Probe echo (RESPONSE_VALUE with body ""), keep reading for the // AUTH_RESPONSE that follows. } return errors.New("auth timeout") } // Exec sends a SERVERDATA_EXECCOMMAND and concatenates RESPONSE_VALUE bodies // until the server goes silent. For responses that straddle multiple packets // the spec recommends a "bogus packet trick" (send a second empty packet and // watch for its echo) but short command output fits in one packet in practice. func (c *sourceClient) Exec(ctx context.Context, cmd string) (string, error) { c.mu.Lock() defer c.mu.Unlock() if dl, ok := ctx.Deadline(); ok { _ = c.conn.SetDeadline(dl) defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck } id := c.assignID() if err := c.writePacket(id, pktExecCommand, cmd); err != nil { return "", fmt.Errorf("write: %w", err) } var out strings.Builder // First read waits longer — some servers (Palworld especially) take 1–3s // to build the response for commands like ShowPlayers. Subsequent reads // use a tight idle timeout so we exit promptly once the stream goes quiet. const ( firstTimeout = 5 * time.Second idleTimeout = 800 * time.Millisecond ) readDeadline := firstTimeout for { _ = c.conn.SetReadDeadline(time.Now().Add(readDeadline)) readDeadline = idleTimeout gotID, gotType, body, err := c.readPacket() if err != nil { var ne net.Error if errors.As(err, &ne) && ne.Timeout() && out.Len() > 0 { return out.String(), nil } if errors.Is(err, io.EOF) && out.Len() > 0 { return out.String(), nil } return out.String(), err } // Source RCON spec says the server MUST echo our request id on // RESPONSE_VALUE. Palworld always returns id=0; Conan Exiles // returns a different arbitrary id. We hold mu.Lock() for the // whole Exec so only one command is ever in flight — ID matching // isn't needed for correctness, so accept any id the server // hands us (except -1, which is reserved for auth failures). if gotID == -1 { continue } // Spec: RESPONSE_VALUE (type 0) carries command output. Conan // Exiles sends command output via AUTH_RESPONSE (type 2) instead — // non-spec but functional. We're past the auth phase by this point // so accepting either type is safe; anything else we skip. if gotType != pktResponseValue && gotType != pktAuthResponse { continue } out.WriteString(body) // Body shorter than ~4000 bytes → likely single-packet response. if len(body) < 3800 { return out.String(), nil } } } // Close shuts down the underlying TCP connection. func (c *sourceClient) Close() error { return c.conn.Close() } func (c *sourceClient) assignID() int32 { c.nextID++ if c.nextID <= 0 { c.nextID = 1 } return c.nextID } // writePacket wire-encodes a Source RCON packet. func (c *sourceClient) writePacket(id, ptype int32, body string) error { bodyBytes := []byte(body) pktSize := int32(4 + 4 + len(bodyBytes) + 2) // id + type + body + 2×NUL if pktSize > maxPktSize { return fmt.Errorf("source rcon: body too large (%d bytes, max %d)", len(bodyBytes), maxPktSize-10) } buf := make([]byte, 4+pktSize) binary.LittleEndian.PutUint32(buf[0:4], uint32(pktSize)) binary.LittleEndian.PutUint32(buf[4:8], uint32(id)) binary.LittleEndian.PutUint32(buf[8:12], uint32(ptype)) copy(buf[12:], bodyBytes) // trailing two NUL bytes already zero _, err := c.conn.Write(buf) return err } // readPacket reads one framed Source RCON packet from the underlying reader. // Returns (id, type, body, err). Body has trailing NULs stripped. func (c *sourceClient) readPacket() (int32, int32, string, error) { var sizeBuf [4]byte if _, err := io.ReadFull(c.reader, sizeBuf[:]); err != nil { return 0, 0, "", err } size := int32(binary.LittleEndian.Uint32(sizeBuf[:])) if size < minPktSize || size > maxPktSize { return 0, 0, "", fmt.Errorf("source rcon: bogus packet size %d", size) } rest := make([]byte, size) if _, err := io.ReadFull(c.reader, rest); err != nil { return 0, 0, "", err } id := int32(binary.LittleEndian.Uint32(rest[0:4])) ptype := int32(binary.LittleEndian.Uint32(rest[4:8])) bodyEnd := size - 2 // trim both trailing NULs if bodyEnd < 8 { bodyEnd = 8 } body := string(rest[8:bodyEnd]) return id, ptype, body, nil }