658bda1d24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 lines
3.0 KiB
Go
98 lines
3.0 KiB
Go
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: ~50–200ms 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 }
|