simpleadmin-web/internal/live/live.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

180 lines
4.4 KiB
Go

// Package live polls the game server through the WebPanelBridge plugin and caches the result, so
// page views never wait on RCON.
package live
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
)
const (
TeamSpectator = 1
TeamT = 2
TeamCT = 3
)
type Player struct {
UserID int `json:"userid"`
Name string `json:"name"`
SteamID string `json:"steamid"`
Team int `json:"team"`
Kills int `json:"kills"`
Deaths int `json:"deaths"`
Ping int `json:"ping"`
Bot bool `json:"bot"`
}
type Status struct {
Online bool `json:"online"`
Error string `json:"-"`
Hostname string `json:"hostname"`
Map string `json:"map"`
MaxPlayers int `json:"maxPlayers"`
ScoreT int `json:"scoreT"`
ScoreCT int `json:"scoreCt"`
Warmup bool `json:"warmup"`
Players []Player `json:"players"`
Updated time.Time `json:"updated"`
}
// Humans returns the connected players that aren't bots.
func (s Status) Humans() []Player {
out := make([]Player, 0, len(s.Players))
for _, p := range s.Players {
if !p.Bot {
out = append(out, p)
}
}
return out
}
// Find returns the online player with this SteamID64.
func (s Status) Find(steamid string) (Player, bool) {
for _, p := range s.Players {
if !p.Bot && p.SteamID == steamid {
return p, true
}
}
return Player{}, false
}
var errNoBridge = errors.New("WebPanelBridge didn't answer; is the plugin installed?")
// Parse reads css_webpanel_status output.
func Parse(out string) (Status, error) {
var st Status
gotServer, gotEnd := false, false
for line := range strings.SplitSeq(out, "\n") {
line = strings.TrimSpace(line)
rest, ok := strings.CutPrefix(line, "wpb ")
if !ok {
continue
}
if rest == "end" {
gotEnd = true
continue
}
if !gotServer {
var srv struct {
V int `json:"v"`
Hostname string `json:"hostname"`
Map string `json:"map"`
MaxPlayers int `json:"maxPlayers"`
ScoreT int `json:"scoreT"`
ScoreCt int `json:"scoreCt"`
Warmup bool `json:"warmup"`
}
if err := json.Unmarshal([]byte(rest), &srv); err != nil || srv.V != 1 {
return st, errors.New("unrecognised WebPanelBridge output")
}
st.Hostname, st.Map, st.MaxPlayers = srv.Hostname, srv.Map, srv.MaxPlayers
st.ScoreT, st.ScoreCT, st.Warmup = srv.ScoreT, srv.ScoreCt, srv.Warmup
gotServer = true
continue
}
var p Player
if err := json.Unmarshal([]byte(rest), &p); err == nil {
st.Players = append(st.Players, p)
}
}
if !gotServer {
return st, errNoBridge
}
if !gotEnd {
return st, errors.New("WebPanelBridge output was cut off")
}
st.Online = true
return st, nil
}
type Poller struct {
rc *rcon.Client
interval time.Duration
mu sync.RWMutex
current Status
}
func NewPoller(rc *rcon.Client, interval time.Duration) *Poller {
return &Poller{rc: rc, interval: interval}
}
// Run polls until ctx is done.
func (p *Poller) Run(ctx context.Context) {
t := time.NewTicker(p.interval)
defer t.Stop()
p.Refresh()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p.Refresh()
}
}
}
// Refresh polls now. Actions call it so the next page load shows their effect.
func (p *Poller) Refresh() Status {
out, err := p.rc.Exec("css_webpanel_status")
var st Status
if err == nil {
st, err = Parse(out)
}
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()
changed := p.current.Online || p.current.Updated.IsZero() || p.current.Error != st.Error
p.mu.RUnlock()
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
}
func (p *Poller) Current() Status {
p.mu.RLock()
defer p.mu.RUnlock()
return p.current
}