diff --git a/internal/live/live.go b/internal/live/live.go index 4a39a18..b922e74 100644 --- a/internal/live/live.go +++ b/internal/live/live.go @@ -151,14 +151,23 @@ func (p *Poller) Refresh() Status { st.Updated = time.Now() if err != nil { 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() - was := p.current.Online || p.current.Updated.IsZero() + changed := p.current.Online || p.current.Updated.IsZero() || p.current.Error != st.Error 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) } } 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.mu.Unlock() return st diff --git a/internal/rcon/rcon.go b/internal/rcon/rcon.go index 0630df5..6298b38 100644 --- a/internal/rcon/rcon.go +++ b/internal/rcon/rcon.go @@ -2,8 +2,12 @@ // // 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 seeing that packet's echo means the command's output is -// complete. If the server never echoes it, reading stops after a short idle timeout instead. +// 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 ( @@ -25,6 +29,10 @@ const ( 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. @@ -112,15 +120,20 @@ func (c *Client) exec(cmd string) (string, error) { } return "", err } - switch pid { - case id: - out.WriteString(body) - got = true - case endID: - // Some servers answer the empty packet with a second, odd-looking packet. Drain it. + 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 } } } diff --git a/internal/rcon/rcon_test.go b/internal/rcon/rcon_test.go index 112a77d..931a252 100644 --- a/internal/rcon/rcon_test.go +++ b/internal/rcon/rcon_test.go @@ -30,7 +30,7 @@ func TestArg(t *testing.T) { // 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) string { +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) @@ -42,7 +42,7 @@ func fakeServer(t *testing.T, password string) string { if err != nil { return } - go serve(c, password) + go serve(c, password, cs2) } }() return ln.Addr().String() @@ -69,8 +69,9 @@ func writePkt(w io.Writer, id, typ int32, body string) { w.Write(buf) } -func serve(c net.Conn, password string) { +func serve(c net.Conn, password string, cs2 bool) { defer c.Close() + pending := "" for { id, typ, body, err := readPkt(c) if err != nil { @@ -85,10 +86,20 @@ func serve(c net.Conn, password string) { 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") } @@ -96,7 +107,7 @@ func serve(c net.Conn, password string) { } func TestExecMultiPacket(t *testing.T) { - addr := fakeServer(t, "pw") + addr := fakeServer(t, "pw", false) c := New(addr, "pw") defer c.Close() for i := 0; i < 3; i++ { @@ -111,7 +122,7 @@ func TestExecMultiPacket(t *testing.T) { } func TestWrongPassword(t *testing.T) { - addr := fakeServer(t, "pw") + addr := fakeServer(t, "pw", false) c := New(addr, "nope") if _, err := c.Exec("status"); err != ErrAuth { 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) } } + +// 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) + } + } +}