panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 5232609719
2160 changed files with 300415 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
package rcon
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
)
// StdioDialer "RCON"s a game by attaching to the container's stdin and writing
// the admin console command, trusting the game to execute it. It's the correct
// choice for games whose admin surface is stdin-only — Valheim, Terraria,
// Barotrauma, Enshrouded, Sons Of The Forest — where no TCP RCON exists.
//
// v1 is write-only: Exec returns empty string. Command output arrives via the
// panel's log pipeline (docker logs → agent → bus → SSE) because the game's
// replies appear on the same stdout the log stream is tailing. A future
// revision could correlate a response window with each write if we ever need
// structured replies for state polling.
//
// Requires the container to have been created with OpenStdin=true; the module
// resolver auto-enables that whenever manifest.Rcon.Adapter == "stdio".
type StdioDialer struct{}
// Name identifies this adapter in the module manifest.
func (StdioDialer) Name() string { return "stdio" }
// Dial attaches to the container's stdio stream. A background goroutine drains
// the multiplexed stdout/stderr so the stream never blocks from our side; the
// docker logs pipeline is the canonical consumer of that output.
func (StdioDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Stdio == nil {
return nil, errors.New("stdio dial: Stdio backend required (agent must provide one)")
}
if opts.ContainerID == "" {
return nil, errors.New("stdio dial: ContainerID required")
}
session, err := opts.Stdio.AttachStdio(ctx, opts.ContainerID)
if err != nil {
return nil, fmt.Errorf("stdio attach %q: %w", opts.ContainerID, err)
}
c := &stdioClient{session: session}
go func() {
// Drain the attached stream so Docker doesn't buffer indefinitely.
// We deliberately discard: the log pipeline already surfaces this
// output to the panel via a separate docker logs call.
_, _ = io.Copy(io.Discard, session)
}()
return c, nil
}
type stdioClient struct {
session io.ReadWriteCloser
mu sync.Mutex
closed bool
}
// Exec writes cmd + "\n" to the container's stdin. Returns empty string; the
// game's response (if any) lands in the log stream and is surfaced in the
// Console tab.
//
// The write is bounded by ctx (and a hard 10s fallback). A raw Write to a
// hijacked stdin pipe can block FOREVER if the container's stdin read side
// stalls (game paused, buffer full, or the process not draining stdin). Because
// the agent dispatches RCON inline on its single recv loop, an unbounded write
// here freezes the WHOLE agent — no stop/start/anything — until restart. That's
// the Valheim+stdio "stop never lands" hang. We run the write in a goroutine and
// abandon it on timeout so the dispatcher stays responsive.
func (c *stdioClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return "", errors.New("stdio: session closed")
}
if ctx == nil {
ctx = context.Background()
}
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.session.Write([]byte(cmd + "\n"))
done <- err
}()
select {
case err := <-done:
if err != nil {
return "", fmt.Errorf("stdio write: %w", err)
}
return "", nil
case <-wctx.Done():
// The write is wedged — leave the goroutine parked (it'll unblock if the
// pipe ever drains, or die when the session closes) but DON'T let it hold
// up the caller / the agent's recv loop.
return "", fmt.Errorf("stdio write timed out after 10s (container stdin not draining): %w", wctx.Err())
}
}
// Close shuts down the hijacked attach connection.
func (c *stdioClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
return c.session.Close()
}