panel v0.9.2 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:06:16 -07:00
commit 0f6aea796c
2164 changed files with 301480 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
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)
}