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 }