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