using System.Buffers.Binary; using System.Net.Sockets; using System.Text; namespace BrassAndSigil.Server.Services; /// /// Minimal Minecraft RCON client (Source RCON protocol). /// Used for sending console commands and reading "list" output for player counts. /// public sealed class RconClient : IDisposable { private const int SERVERDATA_AUTH = 3; private const int SERVERDATA_EXECCOMMAND = 2; private const int SERVERDATA_RESPONSE_VALUE = 0; private TcpClient? _tcp; private NetworkStream? _stream; private int _nextRequestId = 1; public bool Connected => _tcp?.Connected ?? false; public async Task ConnectAsync(string host, int port, string password, CancellationToken ct = default) { _tcp = new TcpClient(); await _tcp.ConnectAsync(host, port, ct); _stream = _tcp.GetStream(); var authId = NextId(); await SendPacketAsync(authId, SERVERDATA_AUTH, password, ct); // Read auth response. Server sends an empty response value first, then the auth result. var (id1, _, _) = await ReadPacketAsync(ct); if (id1 == -1) throw new InvalidOperationException("RCON auth failed (bad password)"); // Auth ok if id matches what we sent } public async Task SendCommandAsync(string command, CancellationToken ct = default) { if (_stream == null) throw new InvalidOperationException("Not connected"); var id = NextId(); await SendPacketAsync(id, SERVERDATA_EXECCOMMAND, command, ct); var (_, _, body) = await ReadPacketAsync(ct); return body; } private int NextId() => Interlocked.Increment(ref _nextRequestId); private async Task SendPacketAsync(int id, int type, string body, CancellationToken ct) { var bodyBytes = Encoding.UTF8.GetBytes(body); var packetSize = 4 + 4 + bodyBytes.Length + 2; // id + type + body + 2 null bytes var buffer = new byte[4 + packetSize]; BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), packetSize); BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4, 4), id); BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(8, 4), type); bodyBytes.CopyTo(buffer.AsSpan(12)); // last two bytes already 0 by default await _stream!.WriteAsync(buffer, ct); } private async Task<(int Id, int Type, string Body)> ReadPacketAsync(CancellationToken ct) { var sizeBuf = new byte[4]; await ReadExactAsync(sizeBuf, ct); var size = BinaryPrimitives.ReadInt32LittleEndian(sizeBuf); if (size < 10 || size > 1024 * 1024) throw new InvalidOperationException($"Bad RCON packet size {size}"); var pkt = new byte[size]; await ReadExactAsync(pkt, ct); var id = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(0, 4)); var type = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(4, 4)); var body = Encoding.UTF8.GetString(pkt, 8, size - 10); // strip 2 trailing nulls return (id, type, body); } private async Task ReadExactAsync(byte[] buffer, CancellationToken ct) { var read = 0; while (read < buffer.Length) { var n = await _stream!.ReadAsync(buffer.AsMemory(read), ct); if (n == 0) throw new EndOfStreamException(); read += n; } } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } }