simpleadmin-web/internal/rcon/rcon_test.go
Astra 10694c69bc Fix live status on CS2: accept RCON output tagged with the end packet's id
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.
2026-09-25 21:27:10 +01:00

158 lines
4.1 KiB
Go

package rcon
import (
"encoding/binary"
"errors"
"io"
"net"
"strings"
"testing"
)
func TestArg(t *testing.T) {
cases := map[string]string{
`plain reason`: "plain reason",
`quote" ; quit`: "quote quit",
"new\nline": "new line",
`back\slash`: "backslash",
" padded ": "padded",
`"; css_addadmin 1 x @css/root`: " css_addadmin 1 x @css/root",
}
for in, want := range cases {
if got := Arg(in, 0); got != strings.TrimSpace(want) {
t.Errorf("Arg(%q) = %q, want %q", in, got, strings.TrimSpace(want))
}
}
if got := Arg("abcdef", 3); got != "abc" {
t.Errorf("max length: got %q", got)
}
}
// fakeServer answers auth, then replies to each command with its text split over two packets,
// and mirrors the empty end-marker packet the way srcds does.
func fakeServer(t *testing.T, password string, cs2 bool) string {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go serve(c, password, cs2)
}
}()
return ln.Addr().String()
}
func readPkt(r io.Reader) (int32, int32, string, error) {
var size int32
if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
return 0, 0, "", err
}
b := make([]byte, size)
if _, err := io.ReadFull(r, b); err != nil {
return 0, 0, "", err
}
return int32(binary.LittleEndian.Uint32(b)), int32(binary.LittleEndian.Uint32(b[4:])), strings.TrimRight(string(b[8:]), "\x00"), nil
}
func writePkt(w io.Writer, id, typ int32, body string) {
buf := make([]byte, 12+len(body)+2)
binary.LittleEndian.PutUint32(buf, uint32(len(body)+10))
binary.LittleEndian.PutUint32(buf[4:], uint32(id))
binary.LittleEndian.PutUint32(buf[8:], uint32(typ))
copy(buf[12:], body)
w.Write(buf)
}
func serve(c net.Conn, password string, cs2 bool) {
defer c.Close()
pending := ""
for {
id, typ, body, err := readPkt(c)
if err != nil {
return
}
switch typ {
case typeAuth:
writePkt(c, id, typeResponseValue, "")
if body == password {
writePkt(c, id, typeAuthResponse, "")
} else {
writePkt(c, -1, typeAuthResponse, "")
}
case typeExecCommand:
if cs2 {
// CS2 answers after reading the end packet too, tagging the output with its id.
pending = "echo:" + body + "\n"
continue
}
out := "echo:" + body + "\n" + strings.Repeat("x", 5000)
writePkt(c, id, typeResponseValue, out[:4000])
writePkt(c, id, typeResponseValue, out[4000:])
case typeResponseValue:
if cs2 {
writePkt(c, id, typeResponseValue, pending)
writePkt(c, id, typeResponseValue, "\x00\x01")
continue
}
writePkt(c, id, typeResponseValue, "")
writePkt(c, id, typeResponseValue, "\x00\x01\x00\x00")
}
}
}
func TestExecMultiPacket(t *testing.T) {
addr := fakeServer(t, "pw", false)
c := New(addr, "pw")
defer c.Close()
for i := 0; i < 3; i++ {
out, err := c.Exec("status")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(out, "echo:status\n") || len(out) != len("echo:status\n")+5000 {
t.Fatalf("got %d bytes: %.40q", len(out), out)
}
}
}
func TestWrongPassword(t *testing.T) {
addr := fakeServer(t, "pw", false)
c := New(addr, "nope")
if _, err := c.Exec("status"); err != ErrAuth {
t.Fatalf("got %v, want ErrAuth", err)
}
}
func TestBackoff(t *testing.T) {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close() // nothing listens here now
c := New(addr, "pw")
if _, err := c.Exec("status"); err == nil {
t.Fatal("expected a connection error")
}
if _, err := c.Exec("status"); !errors.Is(err, ErrDown) {
t.Fatalf("second call should fail fast with ErrDown, got %v", err)
}
}
// CS2 tags output with the end packet's id and ends with a bare "\x00\x01" (seen live on fr04).
func TestExecCS2Ids(t *testing.T) {
c := New(fakeServer(t, "pw", true), "pw")
defer c.Close()
for i := 0; i < 3; i++ {
out, err := c.Exec("css_webpanel_status")
if err != nil {
t.Fatal(err)
}
if out != "echo:css_webpanel_status\n" {
t.Fatalf("got %q", out)
}
}
}