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.
This commit is contained in:
Astra 2026-09-25 21:27:10 +01:00
parent 4f38daf0e6
commit 10694c69bc
3 changed files with 63 additions and 15 deletions

View file

@ -151,14 +151,23 @@ func (p *Poller) Refresh() Status {
st.Updated = time.Now() st.Updated = time.Now()
if err != nil { if err != nil {
st = Status{Error: err.Error(), Updated: st.Updated} st = Status{Error: err.Error(), Updated: st.Updated}
// Log each new kind of failure once, so a wrong password or missing plugin shows up in the
// log without repeating every few seconds. ErrDown only repeats the previous failure.
p.mu.RLock() p.mu.RLock()
was := p.current.Online || p.current.Updated.IsZero() changed := p.current.Online || p.current.Updated.IsZero() || p.current.Error != st.Error
p.mu.RUnlock() p.mu.RUnlock()
if was && !errors.Is(err, rcon.ErrDown) { if changed && !errors.Is(err, rcon.ErrDown) {
slog.Warn("game server status unavailable", "err", err) slog.Warn("game server status unavailable", "err", err)
} }
} }
p.mu.Lock() p.mu.Lock()
if st.Online && !p.current.Online && !p.current.Updated.IsZero() {
slog.Info("game server status available again")
}
// While backing off, keep the underlying error rather than "retrying shortly".
if errors.Is(err, rcon.ErrDown) && p.current.Error != "" {
st.Error = p.current.Error
}
p.current = st p.current = st
p.mu.Unlock() p.mu.Unlock()
return st return st

View file

@ -2,8 +2,12 @@
// //
// One connection is kept open and shared; commands are serialised with a mutex. A response can // 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: // span several packets, so every command is followed by an empty SERVERDATA_RESPONSE_VALUE packet:
// the server answers packets in order, so seeing that packet's echo means the command's output is // the server answers packets in order, so its reply to that packet means the command's output is
// complete. If the server never echoes it, reading stops after a short idle timeout instead. // 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 package rcon
import ( import (
@ -25,6 +29,10 @@ const (
typeAuth = 3 typeAuth = 3
maxPacket = 4096 + 14 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. // ErrAuth means the server rejected the RCON password.
@ -112,15 +120,20 @@ func (c *Client) exec(cmd string) (string, error) {
} }
return "", err return "", err
} }
switch pid { if pid != id && pid != endID {
case id: continue // left over from an earlier command
out.WriteString(body) }
got = true switch {
case endID: case pid == endID && body == endMarker:
// Some servers answer the empty packet with a second, odd-looking packet. Drain it. 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.conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
_, _, _, _ = c.read() _, _, _, _ = c.read()
return out.String(), nil return out.String(), nil
default:
out.WriteString(body)
got = true
} }
} }
} }

View file

@ -30,7 +30,7 @@ func TestArg(t *testing.T) {
// fakeServer answers auth, then replies to each command with its text split over two packets, // 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. // and mirrors the empty end-marker packet the way srcds does.
func fakeServer(t *testing.T, password string) string { func fakeServer(t *testing.T, password string, cs2 bool) string {
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -42,7 +42,7 @@ func fakeServer(t *testing.T, password string) string {
if err != nil { if err != nil {
return return
} }
go serve(c, password) go serve(c, password, cs2)
} }
}() }()
return ln.Addr().String() return ln.Addr().String()
@ -69,8 +69,9 @@ func writePkt(w io.Writer, id, typ int32, body string) {
w.Write(buf) w.Write(buf)
} }
func serve(c net.Conn, password string) { func serve(c net.Conn, password string, cs2 bool) {
defer c.Close() defer c.Close()
pending := ""
for { for {
id, typ, body, err := readPkt(c) id, typ, body, err := readPkt(c)
if err != nil { if err != nil {
@ -85,10 +86,20 @@ func serve(c net.Conn, password string) {
writePkt(c, -1, typeAuthResponse, "") writePkt(c, -1, typeAuthResponse, "")
} }
case typeExecCommand: 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) out := "echo:" + body + "\n" + strings.Repeat("x", 5000)
writePkt(c, id, typeResponseValue, out[:4000]) writePkt(c, id, typeResponseValue, out[:4000])
writePkt(c, id, typeResponseValue, out[4000:]) writePkt(c, id, typeResponseValue, out[4000:])
case typeResponseValue: case typeResponseValue:
if cs2 {
writePkt(c, id, typeResponseValue, pending)
writePkt(c, id, typeResponseValue, "\x00\x01")
continue
}
writePkt(c, id, typeResponseValue, "") writePkt(c, id, typeResponseValue, "")
writePkt(c, id, typeResponseValue, "\x00\x01\x00\x00") writePkt(c, id, typeResponseValue, "\x00\x01\x00\x00")
} }
@ -96,7 +107,7 @@ func serve(c net.Conn, password string) {
} }
func TestExecMultiPacket(t *testing.T) { func TestExecMultiPacket(t *testing.T) {
addr := fakeServer(t, "pw") addr := fakeServer(t, "pw", false)
c := New(addr, "pw") c := New(addr, "pw")
defer c.Close() defer c.Close()
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
@ -111,7 +122,7 @@ func TestExecMultiPacket(t *testing.T) {
} }
func TestWrongPassword(t *testing.T) { func TestWrongPassword(t *testing.T) {
addr := fakeServer(t, "pw") addr := fakeServer(t, "pw", false)
c := New(addr, "nope") c := New(addr, "nope")
if _, err := c.Exec("status"); err != ErrAuth { if _, err := c.Exec("status"); err != ErrAuth {
t.Fatalf("got %v, want ErrAuth", err) t.Fatalf("got %v, want ErrAuth", err)
@ -130,3 +141,18 @@ func TestBackoff(t *testing.T) {
t.Fatalf("second call should fail fast with ErrDown, got %v", err) 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)
}
}
}