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() }