4ccccc6fe2
Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
package rcon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// TelnetDialer speaks 7DTD-style line-oriented telnet: on connect the server
|
|
// sends a "password:" prompt; after the client writes the password and a
|
|
// newline, commands are one-line writes and responses are everything read
|
|
// until the connection goes quiet for a short idle period.
|
|
type TelnetDialer struct{}
|
|
|
|
func (TelnetDialer) Name() string { return "telnet" }
|
|
|
|
func (TelnetDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
|
|
if opts.Host == "" || opts.Port == 0 {
|
|
return nil, errors.New("telnet dial: host and port are required")
|
|
}
|
|
addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port)
|
|
|
|
timeout := opts.ConnectTimeout
|
|
if timeout == 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
|
|
d := &net.Dialer{Timeout: timeout}
|
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("telnet dial %s: %w", addr, err)
|
|
}
|
|
|
|
idle := opts.ReadIdle
|
|
if idle == 0 {
|
|
idle = 250 * time.Millisecond
|
|
}
|
|
c := &telnetClient{
|
|
conn: conn,
|
|
readIdle: idle,
|
|
}
|
|
if err := c.authenticate(opts.Password); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("telnet auth: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// telnetClient is safe for concurrent Exec via mu serialization.
|
|
type telnetClient struct {
|
|
conn net.Conn
|
|
readIdle time.Duration
|
|
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (c *telnetClient) authenticate(password string) error {
|
|
// Read until the first "password:" prompt arrives, with a 5s cap.
|
|
if _, err := c.readUntil("password:", 5*time.Second); err != nil {
|
|
return fmt.Errorf("read password prompt: %w", err)
|
|
}
|
|
if _, err := c.conn.Write([]byte(password + "\n")); err != nil {
|
|
return fmt.Errorf("write password: %w", err)
|
|
}
|
|
// Drain whatever welcome/banner text the server sends after auth.
|
|
_, _ = c.readQuiet(1500*time.Millisecond, 1500*time.Millisecond)
|
|
return nil
|
|
}
|
|
|
|
// Exec sends one line-terminated command and returns everything the server
|
|
// writes back before going quiet for readIdle.
|
|
func (c *telnetClient) Exec(ctx context.Context, cmd string) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Wire ctx deadline into the conn's read/write timeouts.
|
|
if dl, ok := ctx.Deadline(); ok {
|
|
_ = c.conn.SetDeadline(dl)
|
|
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
|
|
}
|
|
|
|
if _, err := c.conn.Write([]byte(cmd + "\n")); err != nil {
|
|
return "", fmt.Errorf("write cmd: %w", err)
|
|
}
|
|
return c.readQuiet(c.firstByteTimeout(), c.readIdle)
|
|
}
|
|
|
|
func (c *telnetClient) Close() error {
|
|
return c.conn.Close()
|
|
}
|
|
|
|
// firstByteTimeout is how long we wait for the server to START replying to a
|
|
// command. A busy 7DTD server (heavy load, blood moon, GC pause) can take well
|
|
// over readIdle to send its first byte, so this must be generous — otherwise
|
|
// readQuiet times out with zero bytes and returns an empty response even though
|
|
// the command executed fine on the server.
|
|
func (c *telnetClient) firstByteTimeout() time.Duration {
|
|
return 4 * time.Second
|
|
}
|
|
|
|
// readQuiet reads a command response. It waits up to `first` for the first byte
|
|
// (server may be slow to start replying under load), then once bytes arrive it
|
|
// uses the short `idle` gap to detect end-of-response. Returns what it read, or
|
|
// an error on a real (non-timeout) failure.
|
|
func (c *telnetClient) readQuiet(first, idle time.Duration) (string, error) {
|
|
var buf bytes.Buffer
|
|
tmp := make([]byte, 4096)
|
|
for {
|
|
// Before any data, allow the longer first-byte window; after data
|
|
// starts flowing, switch to the short idle gap.
|
|
wait := idle
|
|
if buf.Len() == 0 {
|
|
wait = first
|
|
}
|
|
_ = c.conn.SetReadDeadline(time.Now().Add(wait))
|
|
n, err := c.conn.Read(tmp)
|
|
if n > 0 {
|
|
buf.Write(tmp[:n])
|
|
}
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
// Timeout with data → normal end of response.
|
|
// Timeout with NO data even after the first-byte window → the
|
|
// server genuinely sent nothing (or is unreachable); return
|
|
// empty and let the caller decide.
|
|
return buf.String(), nil
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
return buf.String(), nil
|
|
}
|
|
return buf.String(), err
|
|
}
|
|
}
|
|
}
|
|
|
|
// readUntil reads up to `overall` time waiting for `substr` (case-insensitive)
|
|
// to appear in the accumulated buffer. Returns what it saw either way.
|
|
func (c *telnetClient) readUntil(substr string, overall time.Duration) (string, error) {
|
|
var buf bytes.Buffer
|
|
tmp := make([]byte, 4096)
|
|
deadline := time.Now().Add(overall)
|
|
want := strings.ToLower(substr)
|
|
for time.Now().Before(deadline) {
|
|
_ = c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
n, err := c.conn.Read(tmp)
|
|
if n > 0 {
|
|
buf.Write(tmp[:n])
|
|
if strings.Contains(strings.ToLower(buf.String()), want) {
|
|
return buf.String(), nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
continue
|
|
}
|
|
return buf.String(), err
|
|
}
|
|
}
|
|
return buf.String(), fmt.Errorf("timed out after %s waiting for %q", overall, substr)
|
|
}
|