Files
panel/agent/internal/state/tracker.go
T
2026-07-14 19:19:43 -07:00

536 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package state runs the per-instance state-tracking pipeline:
// - RCON poll loops that execute manifest-declared commands and parse the
// output into AppStateUpdate messages (players online, etc.)
// - log-line event matching that runs manifest-declared regexes against
// each log line and emits PlayerEvent messages (join/leave/chat/death)
//
// A Tracker is owned by the dispatcher for the lifetime of a running
// instance. It is stopped by cancelling the context passed to Run.
package state
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"regexp"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/rcon"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Emitter is the upward path for tracker output. Implementations forward
// messages onto the gRPC stream back to the Controller.
type Emitter interface {
EmitAppState(*panelv1.AppStateUpdate)
EmitPlayerEvent(*panelv1.PlayerEvent)
}
// Config wires the tracker to an instance + its RCON endpoint.
type Config struct {
InstanceID string
Manifest *modulepkg.Manifest
RCONAddr string // "host:port" — required for tcp adapters (telnet, source_rcon)
RCONPassword string
// PasswordFunc, if set, overrides RCONPassword on each dial attempt.
// Used when the module's entrypoint generates an RCON secret at first
// boot (e.g. 7DTD's TelnetPassword in /game-saves/.panel-telnet-password).
// Called with the dial context; the returned string is used as the
// password for that single attempt.
PasswordFunc func(ctx context.Context) (string, error)
Emitter Emitter
// ContainerID is the Docker container name/ID for the stdio adapter.
// Ignored by tcp adapters. The stdio adapter calls Stdio.AttachStdio
// with this id to get a stdin/stdout handle to the running game.
ContainerID string
// Stdio is the backend that knows how to attach to a container's
// stdio. Required for stdio adapter, nil otherwise.
Stdio rcon.StdioBackend
// OnRconResult is an optional callback invoked after every RCON
// exec attempt (poll loop or operator-driven). success=true means
// the engine answered; success=false means the exec failed (dial,
// timeout, dead conn). Used by the dispatcher's arkHangGuard to
// count consecutive failures and trigger an auto-restart when the
// Wine 9 listener wedges. Nil-safe: callers should check before invoke.
OnRconResult func(success bool)
}
// Tracker is created at instance start time and run in a goroutine.
type Tracker struct {
log *slog.Logger
cfg Config
events []compiledEvent
// stateRE holds the state-source parse regexes, compiled once at New so
// a bad pattern surfaces as a construction error instead of silently
// recompiling (and silently failing) on every poll tick. Keyed by the
// pattern string — pollOnce receives the StateSource by value, so the
// pattern is the stable identity.
stateRE map[string]*regexp.Regexp
mu sync.Mutex
client rcon.Client
}
type compiledEvent struct {
name string
re *regexp.Regexp
kind panelv1.PlayerEvent_Kind
}
// New constructs a Tracker. Event patterns are compiled now so config errors
// surface before Run is called.
func New(log *slog.Logger, cfg Config) (*Tracker, error) {
if cfg.Manifest == nil {
return nil, errors.New("manifest is required")
}
if cfg.Emitter == nil {
return nil, errors.New("emitter is required")
}
events, err := compileEvents(cfg.Manifest.Events)
if err != nil {
return nil, err
}
// Compile state-source parse patterns up front — a bad pattern is a
// manifest bug and should fail loudly at init, not degrade into a
// per-poll recompile that silently yields no state updates.
stateRE := map[string]*regexp.Regexp{}
for i := range cfg.Manifest.StateSources {
p := cfg.Manifest.StateSources[i].Parse
if p == nil || p.Kind != "regex" || p.Pattern == "" {
continue
}
re, err := regexp.Compile(p.Pattern)
if err != nil {
return nil, fmt.Errorf("state source %d: compile pattern: %w", i, err)
}
stateRE[p.Pattern] = re
}
return &Tracker{
log: log.With("instance_id", cfg.InstanceID, "component", "state"),
cfg: cfg,
events: events,
stateRE: stateRE,
}, nil
}
// Client returns the currently-connected RCON client, or nil if the tracker
// hasn't finished dialing (or the manifest has no RCON at all). Used by the
// dispatcher to handle ad-hoc RCON commands from operators.
func (t *Tracker) Client() rcon.Client {
t.mu.Lock()
defer t.mu.Unlock()
return t.client
}
// Exec runs one operator command via the tracker's cached client. On a dead
// connection (EOF, broken pipe — Palworld closes RCON after 60s idle) we
// redial once and retry, so operators don't see the 60s-idle EOF in the UI.
// Returns ("", "not_connected", ...) if the tracker hasn't finished dialing.
func (t *Tracker) Exec(ctx context.Context, cmd string) (string, error) {
t.mu.Lock()
client := t.client
t.mu.Unlock()
if client == nil {
return "", errors.New("RCON not yet connected; try again in a moment")
}
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := client.Exec(execCtx, cmd)
if err == nil || !isDeadConnErr(err) {
return out, err
}
t.redial(ctx)
t.mu.Lock()
client = t.client
t.mu.Unlock()
if client == nil {
return out, err
}
retryCtx, retryCancel := context.WithTimeout(ctx, 10*time.Second)
defer retryCancel()
return client.Exec(retryCtx, cmd)
}
// OnLogLine runs each log line through compiled event patterns and emits a
// PlayerEvent on the first match. Called from the dispatcher's log pump; must
// be fast and non-blocking.
func (t *Tracker) OnLogLine(line string) {
for _, ev := range t.events {
m := ev.re.FindStringSubmatch(line)
if m == nil {
continue
}
name := firstGroup(ev.re, m, "name", "player_name")
id := firstGroup(ev.re, m, "platform_id", "owner", "cross_id", "id", "player_id")
detail := firstGroup(ev.re, m, "msg", "detail", "reason")
t.cfg.Emitter.EmitPlayerEvent(&panelv1.PlayerEvent{
InstanceId: t.cfg.InstanceID,
Kind: ev.kind,
PlayerName: name,
PlayerId: id,
Detail: detail,
At: timestamppb.Now(),
})
return
}
}
// Run connects to RCON (with retries) and starts one goroutine per declared
// RCON state source. Returns when ctx is cancelled.
//
// If the manifest has no RCON config at all, Run is a no-op that blocks on
// ctx.Done. If it has RCON but no state sources, we still dial once so
// operator ad-hoc commands from the UI work — state-source poll loops just
// don't spawn.
func (t *Tracker) Run(ctx context.Context) {
if t.cfg.Manifest.RCON == nil {
<-ctx.Done()
return
}
client, err := t.dialWithRetries(ctx)
if err != nil {
// Context cancelled before we could connect; nothing to do.
return
}
t.mu.Lock()
t.client = client
t.mu.Unlock()
defer func() {
t.mu.Lock()
defer t.mu.Unlock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
}()
t.log.Info("rcon connected", "addr", t.cfg.RCONAddr, "adapter", t.cfg.Manifest.RCON.Adapter)
var wg sync.WaitGroup
for i := range t.cfg.Manifest.StateSources {
ss := t.cfg.Manifest.StateSources[i]
if ss.Type != "rcon" || ss.Every.Std() <= 0 {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
t.runRCONPoller(ctx, ss)
}()
}
// Block until the tracker is cancelled so the defer (which closes the
// rcon client) fires on teardown, not immediately. With no rcon state
// sources wg.Wait() returns instantly — without this extra wait the
// client would vanish right after Run dialed, making ad-hoc commands
// from the UI fail with "RCON not yet connected" forever.
<-ctx.Done()
wg.Wait()
}
func (t *Tracker) hasRCONStateSource() bool {
for _, ss := range t.cfg.Manifest.StateSources {
if ss.Type == "rcon" && ss.Every.Std() > 0 {
return true
}
}
return false
}
func (t *Tracker) dialWithRetries(ctx context.Context) (rcon.Client, error) {
rc := t.cfg.Manifest.RCON
dialer, err := rcon.DialerFor(rc.Adapter)
if err != nil {
return nil, err
}
// stdio has no host/port — it attaches to the running container's stdin.
// We still retry-loop because the container may not be up yet when the
// tracker starts (race between Start + activate).
if rc.Adapter == "stdio" {
backoff := 2 * time.Second
for {
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
client, err := dialer.Dial(dialCtx, rcon.DialOptions{
ContainerID: t.cfg.ContainerID,
Stdio: t.cfg.Stdio,
})
cancel()
if err == nil {
return client, nil
}
t.log.Warn("stdio attach failed, retrying", "err", err, "backoff", backoff)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
host, portStr, err := net.SplitHostPort(t.cfg.RCONAddr)
if err != nil {
return nil, fmt.Errorf("rcon addr %q: %w", t.cfg.RCONAddr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("rcon port %q: %w", portStr, err)
}
// Dial-retry backoff capped at 8s (not 30s) — during the post-Start
// warm-up window the server's RCON flips between responsive and slow
// every few seconds, and a 32s ceiling meant one unlucky timeout
// stranded the panel for half a minute before the next retry. Cap
// at 8s so ragnarok/etc. get back onto RCON within a few seconds of
// ASA actually being ready, without hammering the port on a truly
// dead server (8s × a few retries is ~30s before the tracker gives
// up per-cycle, which the agent's outer loop still reschedules).
backoff := 2 * time.Second
for {
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
// Resolve password per-attempt when PasswordFunc is set — the
// module's entrypoint may be mid-generation on first boot, so re-
// reading on each retry lets us pick up the final value as soon
// as it's written.
pw := t.cfg.RCONPassword
if t.cfg.PasswordFunc != nil {
if p, err := t.cfg.PasswordFunc(dialCtx); err == nil && p != "" {
pw = p
}
}
client, err := dialer.Dial(dialCtx, rcon.DialOptions{
Host: host,
Port: port,
Password: pw,
ConnectTimeout: 5 * time.Second,
ContainerID: t.cfg.ContainerID,
})
cancel()
if err == nil {
return client, nil
}
t.log.Warn("rcon dial failed, retrying", "err", err, "backoff", backoff)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
if backoff < 8*time.Second {
backoff *= 2
}
}
}
func (t *Tracker) runRCONPoller(ctx context.Context, ss modulepkg.StateSource) {
every := ss.Every.Std()
ticker := time.NewTicker(every)
defer ticker.Stop()
t.pollOnce(ctx, ss) // immediate first tick
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.pollOnce(ctx, ss)
}
}
}
func (t *Tracker) pollOnce(ctx context.Context, ss modulepkg.StateSource) {
t.mu.Lock()
client := t.client
t.mu.Unlock()
if client == nil {
return
}
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := client.Exec(execCtx, ss.Command)
if err != nil {
t.log.Warn("rcon exec failed", "cmd", ss.Command, "err", err)
if t.cfg.OnRconResult != nil {
t.cfg.OnRconResult(false)
}
if isDeadConnErr(err) {
t.redial(ctx)
}
return
}
if t.cfg.OnRconResult != nil {
t.cfg.OnRconResult(true)
}
app := t.parseState(out, ss)
if app == nil {
// Parser yielded no structured fields (regex didn't match this game's
// output, fields all empty, etc.) but the RCON exec succeeded — that
// alone is proof the server is responsive. Emit a bare update keyed
// by InstanceID so the dispatcher can use it to promote a stale
// CRASHED status back to RUNNING.
app = &panelv1.AppStateUpdate{InstanceId: t.cfg.InstanceID}
}
t.cfg.Emitter.EmitAppState(app)
}
// isDeadConnErr returns true for errors that indicate the RCON socket is no
// longer usable (EOF, connection reset, broken pipe). Palworld's RCON, for
// instance, closes the socket after ~60s idle — any subsequent Exec fails
// with EOF and stays broken until we redial.
func isDeadConnErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "EOF") ||
strings.Contains(s, "broken pipe") ||
strings.Contains(s, "connection reset") ||
strings.Contains(s, "forcibly closed") ||
strings.Contains(s, "use of closed network connection")
}
// redial closes the current client and dials a fresh one. Runs inline with
// the poll loop so the next tick gets a working client. On failure we leave
// t.client = nil and the next poll will call us again.
func (t *Tracker) redial(ctx context.Context) {
t.mu.Lock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
t.mu.Unlock()
t.log.Info("rcon redialing after dead connection")
fresh, err := t.dialWithRetries(ctx)
if err != nil {
t.log.Warn("rcon redial failed", "err", err)
return
}
t.mu.Lock()
t.client = fresh
t.mu.Unlock()
t.log.Info("rcon reconnected", "addr", t.cfg.RCONAddr, "adapter", t.cfg.Manifest.RCON.Adapter)
}
// parseState runs the configured parser against raw RCON output using the
// regex precompiled at New, returning an AppStateUpdate or nil if nothing
// matched.
func (t *Tracker) parseState(out string, ss modulepkg.StateSource) *panelv1.AppStateUpdate {
if ss.Parse == nil || ss.Parse.Kind != "regex" {
return nil
}
re := t.stateRE[ss.Parse.Pattern]
if re == nil {
return nil
}
return parseRegexCompiled(t.cfg.InstanceID, out, re, ss.Parse)
}
// parseStateOutput is the uncached variant (compiles the pattern per call).
// Kept for tests and out-of-tracker callers; the tracker's poll loop uses
// the precompiled parseState path.
func parseStateOutput(instanceID, out string, ss modulepkg.StateSource) *panelv1.AppStateUpdate {
if ss.Parse == nil {
return nil
}
switch ss.Parse.Kind {
case "regex":
return parseRegex(instanceID, out, ss.Parse)
default:
return nil
}
}
func parseRegex(instanceID, out string, p *modulepkg.ParseConfig) *panelv1.AppStateUpdate {
re, err := regexp.Compile(p.Pattern)
if err != nil {
return nil
}
return parseRegexCompiled(instanceID, out, re, p)
}
func parseRegexCompiled(instanceID, out string, re *regexp.Regexp, p *modulepkg.ParseConfig) *panelv1.AppStateUpdate {
m := re.FindStringSubmatch(out)
if m == nil {
return nil
}
app := &panelv1.AppStateUpdate{
InstanceId: instanceID,
At: timestamppb.Now(),
}
for outName, groupName := range p.Fields {
val := firstGroup(re, m, groupName)
switch outName {
case "players_online":
if n, err := strconv.Atoi(val); err == nil {
app.PlayersOnline = int32(n)
}
case "players_max":
if n, err := strconv.Atoi(val); err == nil {
app.PlayersMax = int32(n)
}
case "uptime_seconds":
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
app.UptimeSeconds = n
}
default:
if app.ModuleFields == nil {
app.ModuleFields = map[string]string{}
}
app.ModuleFields[outName] = val
}
}
return app
}
func compileEvents(events map[string]modulepkg.Event) ([]compiledEvent, error) {
out := make([]compiledEvent, 0, len(events))
for name, ev := range events {
re, err := regexp.Compile(ev.Pattern)
if err != nil {
return nil, fmt.Errorf("event %q: compile pattern: %w", name, err)
}
out = append(out, compiledEvent{
name: name,
re: re,
kind: parseKind(ev.Kind),
})
}
return out, nil
}
func parseKind(s string) panelv1.PlayerEvent_Kind {
switch s {
case "join":
return panelv1.PlayerEvent_KIND_JOIN
case "leave":
return panelv1.PlayerEvent_KIND_LEAVE
case "chat":
return panelv1.PlayerEvent_KIND_CHAT
case "death":
return panelv1.PlayerEvent_KIND_DEATH
default:
return panelv1.PlayerEvent_KIND_CUSTOM
}
}
// firstGroup returns the first non-empty named submatch from names. Missing
// or empty groups are treated as absent so we try the next name.
func firstGroup(re *regexp.Regexp, m []string, names ...string) string {
for _, name := range names {
i := re.SubexpIndex(name)
if i >= 0 && i < len(m) && m[i] != "" {
return m[i]
}
}
return ""
}