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) }