Shown only where GroupVerify is: staff get the Verification page once its gv_* tables are in the database, and the public site shows "Verified" only while it's on (enabled, with a group) and not missing from css_plugins list, which the poller reads once a minute over RCON. - Verification page: plugin state and version, Steam group, saved member list, overrides. - Overrides (@zio/verify, not on yourself unless Root): give, change, remove; player drawer shows why a player is or isn't verified. - Root sets the group and turns it on/off: saved to gv_settings, and sent as css_gv_* when the plugin is loaded so it applies at once. - gv_* times use GroupVerify's own timezone setting. - MySQL integration test (SAW_TEST_DSN), unit tests for plugin list parsing and the show rule.
249 lines
6.2 KiB
Go
249 lines
6.2 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"
|
|
"regexp"
|
|
"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
|
|
}
|
|
|
|
// pluginLine is one plugin in `css_plugins list`: [#3:LOADED]: "GroupVerify" (0.2.0) by astra
|
|
var pluginLine = regexp.MustCompile(`\[#\d+:([A-Z]+)\]: "([^"]*)" \(([^)]*)\)`)
|
|
|
|
// ParsePlugins reads `css_plugins list` output into loaded plugins' versions by ModuleName. ok is
|
|
// false when the output isn't a plugin list at all.
|
|
func ParsePlugins(out string) (loaded map[string]string, ok bool) {
|
|
if !strings.Contains(out, "List of all plugins") {
|
|
return nil, false
|
|
}
|
|
loaded = map[string]string{}
|
|
for _, m := range pluginLine.FindAllStringSubmatch(out, -1) {
|
|
if m[1] == "LOADED" {
|
|
loaded[m[2]] = m[3]
|
|
}
|
|
}
|
|
return loaded, true
|
|
}
|
|
|
|
// Whether a CSS plugin is loaded on the server, as far as the last check knows.
|
|
type PluginState string
|
|
|
|
const (
|
|
PluginLoaded PluginState = "loaded"
|
|
PluginMissing PluginState = "missing"
|
|
PluginUnknown PluginState = "unknown" // server down, or not checked yet
|
|
)
|
|
|
|
// pluginInterval is how often the plugin list is reread; plugins rarely change.
|
|
const pluginInterval = time.Minute
|
|
|
|
type Poller struct {
|
|
rc *rcon.Client
|
|
interval time.Duration
|
|
|
|
mu sync.RWMutex
|
|
current Status
|
|
|
|
// From css_plugins list, nil while unknown.
|
|
plugins map[string]string
|
|
pluginsAt time.Time
|
|
}
|
|
|
|
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
|
|
if !st.Online {
|
|
p.plugins = nil
|
|
}
|
|
checkPlugins := st.Online && time.Since(p.pluginsAt) >= pluginInterval
|
|
if checkPlugins {
|
|
p.pluginsAt = time.Now() // claimed, so concurrent refreshes don't all ask
|
|
}
|
|
p.mu.Unlock()
|
|
if checkPlugins {
|
|
p.refreshPlugins()
|
|
}
|
|
return st
|
|
}
|
|
|
|
func (p *Poller) refreshPlugins() {
|
|
out, err := p.rc.Exec("css_plugins list")
|
|
loaded, ok := ParsePlugins(out)
|
|
if err != nil || !ok {
|
|
loaded = nil
|
|
}
|
|
p.mu.Lock()
|
|
p.plugins = loaded
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
// Plugin reports whether a CSS plugin (by ModuleName) is loaded, and its version if it is.
|
|
func (p *Poller) Plugin(name string) (PluginState, string) {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
if p.plugins == nil {
|
|
return PluginUnknown, ""
|
|
}
|
|
if v, ok := p.plugins[name]; ok {
|
|
return PluginLoaded, v
|
|
}
|
|
return PluginMissing, ""
|
|
}
|
|
|
|
func (p *Poller) Current() Status {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.current
|
|
}
|