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.
180 lines
4.4 KiB
Go
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
|
|
}
|