8a94ffd58f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
111 lines
3.8 KiB
Go
111 lines
3.8 KiB
Go
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()
|
|
}
|