Files
panel/agent/internal/rcon/docker_exec.go
T
2026-07-14 19:22:07 -07:00

98 lines
3.0 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 rcon
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
)
// DockerExecDialer runs per command, opening
// a fresh socket inside the container's network namespace each time and
// closing it on completion. Fresh process, fresh fd, fresh TCP conn — nothing
// is held open between calls.
//
// Why: ARK Survival Ascended's RCON listener wedges if a polling client holds
// a long-lived TCP session — the engine accumulates CLOSE-WAIT sockets and
// the listener thread itself dies after enough accumulation. The wedge is
// not recoverable without restarting the engine. Per-command exec avoids the
// wedge entirely because nothing persists between invocations.
//
// Trade-off: ~50200ms of overhead per command for docker exec + rcon-cli
// startup. Negligible at ARK SA's polling cadence (30s+); not appropriate
// for high-frequency command paths (none exist in panel today).
//
// Requirements:
// - Container has rcon-cli on PATH (acekorneya/asa_server: yes).
// - Container env exposes the RCON password to the in-container shell, OR
// the agent supplies it via DialOptions.Password (we pass it in).
// - Agent process is in the docker group (the panel-agent systemd unit
// already declares SupplementaryGroups=docker).
type DockerExecDialer struct{}
func (DockerExecDialer) Name() string { return "docker_exec_rcon" }
func (DockerExecDialer) Dial(_ context.Context, opts DialOptions) (Client, error) {
if opts.ContainerID == "" {
return nil, errors.New("docker_exec_rcon dial: ContainerID required")
}
port := ""
if opts.Port != 0 {
port = strconv.Itoa(opts.Port)
}
return &dockerExecClient{
containerID: opts.ContainerID,
password: opts.Password,
port: port,
}, nil
}
type dockerExecClient struct {
containerID string
password string
port string
mu sync.Mutex
}
// ansiRe matches CSI escape sequences. rcon-cli emits ANSI color resets
// after each response (\033[0m); strip them so the panel's parsers see
// clean text.
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
// Exec runs and returns the cleaned stdout.
func (c *dockerExecClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
args := []string{"exec", c.containerID, "rcon-cli", "--host", "localhost"}
if c.port != "" {
args = append(args, "--port", c.port)
}
if c.password != "" {
args = append(args, "--password", c.password)
}
args = append(args, cmd)
dockerCmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr bytes.Buffer
dockerCmd.Stdout = &stdout
dockerCmd.Stderr = &stderr
if err := dockerCmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return "", fmt.Errorf("docker exec rcon-cli: %s", errMsg)
}
out := ansiRe.ReplaceAllString(stdout.String(), "")
return strings.TrimSpace(out), nil
}
// Close is a no-op — there's nothing held open between Exec calls.
func (c *dockerExecClient) Close() error { return nil }