panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user