Add C# dedicated server replacement

This commit is contained in:
Nomads_Reach
2026-08-16 02:23:04 -04:00
parent 0024436ce8
commit ddadf87423
17 changed files with 3516 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace CommonwealthOnline.Server;
internal static class AdminTokenStore
{
public static string LoadOrCreate(string path)
{
var full = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
if (File.Exists(full))
{
var existing = File.ReadAllText(full, Encoding.UTF8).Trim();
if (!string.IsNullOrEmpty(existing)) return existing;
}
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var temp = Path.Combine(Path.GetDirectoryName(full)!, $".{Path.GetFileName(full)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp");
try
{
using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
{
writer.WriteLine(token);
writer.Flush();
stream.Flush(true);
}
BanStore.TryRestrictPermissions(temp);
File.Move(temp, full, true);
BanStore.TryRestrictPermissions(full);
return token;
}
catch
{
try { File.Delete(temp); } catch { }
if (File.Exists(full))
{
var existing = File.ReadAllText(full, Encoding.UTF8).Trim();
if (!string.IsNullOrEmpty(existing)) return existing;
}
throw;
}
}
public static string Load(string path)
{
var token = File.ReadAllText(Path.GetFullPath(path), Encoding.UTF8).Trim();
return string.IsNullOrEmpty(token) ? throw new InvalidOperationException($"Admin token file is empty: {path}") : token;
}
public static bool EqualsConstantTime(string supplied, string expected)
{
var left = Encoding.UTF8.GetBytes(supplied);
var right = Encoding.UTF8.GetBytes(expected);
return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right);
}
}
internal sealed class AdminControlServer : IAsyncDisposable
{
private readonly AuthoritativeServer _server;
private readonly ServerOptions _options;
private readonly string _token;
private readonly CancellationTokenSource _shutdown = new();
private readonly List<Task> _clientTasks = new();
private readonly object _gate = new();
private TcpListener? _listener;
private Task? _acceptTask;
public AdminControlServer(AuthoritativeServer server, ServerOptions options)
{
_server = server;
_options = options;
_token = AdminTokenStore.LoadOrCreate(options.AdminTokenPath);
}
public void Start()
{
_listener = new TcpListener(IPAddress.Loopback, _options.AdminPort);
_listener.Start();
_acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token));
_server.Log($"Admin control listening on 127.0.0.1:{_options.AdminPort} (localhost, authenticated)");
}
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
TcpClient client;
try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch (SocketException) { if (cancellationToken.IsCancellationRequested) break; else continue; }
var task = HandleClientAsync(client, cancellationToken);
lock (_gate) _clientTasks.Add(task);
_ = task.ContinueWith(_ => { lock (_gate) _clientTasks.Remove(task); }, TaskScheduler.Default);
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
{
using (client)
{
var stream = client.GetStream();
var read = new byte[4096];
using var line = new MemoryStream();
try
{
while (!cancellationToken.IsCancellationRequested)
{
var count = await stream.ReadAsync(read, cancellationToken).ConfigureAwait(false);
if (count == 0) break;
for (var i = 0; i < count; i++)
{
if (read[i] == (byte)'\n')
{
var data = line.ToArray(); line.SetLength(0);
if (data.Length == 0) continue;
var response = data.Length > ProtocolConstants.MaxMessageBytes
? Fail("Admin request is too large.")
: await DispatchAsync(data).ConfigureAwait(false);
var encoded = JsonSerializer.SerializeToUtf8Bytes(response);
await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
continue;
}
line.WriteByte(read[i]);
if (line.Length > ProtocolConstants.MaxMessageBytes) return;
}
}
}
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { }
}
}
private async Task<JsonObject> DispatchAsync(byte[] data)
{
JsonObject request;
try { request = JsonNode.Parse(data) as JsonObject ?? throw new JsonException("request must be an object"); }
catch (JsonException ex) { return Fail($"Invalid JSON: {ex.Message}"); }
var supplied = JsonHelpers.String(request["adminToken"]);
request.Remove("adminToken");
if (supplied is null || !AdminTokenStore.EqualsConstantTime(supplied, _token)) return Fail("Unauthorized admin request.");
var command = (JsonHelpers.String(request["cmd"] ?? request["command"]) ?? string.Empty).Trim().ToLowerInvariant();
var id = request["id"]?.DeepClone();
JsonObject Ok(JsonNode? body = null, string? message = null)
{
var response = new JsonObject { ["ok"] = true };
if (id is not null) response["id"] = id.DeepClone();
if (!string.IsNullOrEmpty(message)) response["message"] = message;
if (body is not null) response["data"] = body;
return response;
}
JsonObject LocalFail(string error)
{
var response = Fail(error);
if (id is not null) response["id"] = id.DeepClone();
return response;
}
switch (command)
{
case "ping": return Ok(new JsonObject { ["pong"] = true });
case "stats": case "status": return Ok(_server.GetAdminStats());
case "clients": case "users":
{
var clients = _server.GetAdminClients();
return Ok(new JsonObject { ["total_clients"] = clients.Count, ["clients"] = clients });
}
case "bans":
{
var bans = new JsonArray(_server.ListBans().Select(x => (JsonNode)new JsonObject { ["ip"] = x.Ip, ["reason"] = x.Reason, ["bannedAt"] = x.BannedAt }).ToArray());
return Ok(new JsonObject { ["bans"] = bans });
}
case "kick":
{
if (!JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId)) return LocalFail("kick requires playerId");
var result = await _server.KickAsync(playerId, JsonHelpers.String(request["reason"]) ?? string.Empty).ConfigureAwait(false);
return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message);
}
case "ban":
{
var reason = JsonHelpers.String(request["reason"]) ?? string.Empty;
if (JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId))
{
var result = await _server.BanPlayerAsync(playerId, reason).ConfigureAwait(false);
return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message);
}
var ip = JsonHelpers.String(request["ip"]);
if (string.IsNullOrWhiteSpace(ip)) return LocalFail("ban requires playerId or ip");
var ban = await _server.BanIpAsync(ip, reason).ConfigureAwait(false);
return ban.Ok ? Ok(ban.Data, ban.Message) : LocalFail(ban.Message);
}
case "unban":
{
var ip = JsonHelpers.String(request["ip"]);
if (string.IsNullOrWhiteSpace(ip)) return LocalFail("unban requires ip");
var result = _server.Unban(ip);
return result.Ok ? Ok(new JsonObject { ["ip"] = ip }, result.Message) : LocalFail(result.Message);
}
case "world_time":
{
var hhmm = JsonHelpers.String(request["hhmm"] ?? request["time"]);
if (string.IsNullOrWhiteSpace(hhmm)) return LocalFail("world_time requires hhmm");
return await _server.SetServerTimeAsync(hhmm).ConfigureAwait(false) ? Ok(message: $"Server time set to {hhmm}.") : LocalFail("Invalid time format. Use HHmm (e.g., 1430 for 14:30).");
}
case "world_weather":
{
var weather = JsonHelpers.String(request["weather"] ?? request["fw"]);
if (string.IsNullOrWhiteSpace(weather)) return LocalFail("world_weather requires weather");
return await _server.SetServerWeatherAsync(weather).ConfigureAwait(false) ? Ok(message: $"Server weather updated to {weather}.") : LocalFail("Invalid weather ID. Use an 8-digit hex form ID.");
}
default: return LocalFail($"Unknown admin command: {(string.IsNullOrEmpty(command) ? "(empty)" : command)}");
}
}
private static JsonObject Fail(string error) => new() { ["ok"] = false, ["error"] = error };
public async ValueTask DisposeAsync()
{
_shutdown.Cancel();
try { _listener?.Stop(); } catch { }
if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } }
Task[] tasks; lock (_gate) tasks = _clientTasks.ToArray();
if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } }
_shutdown.Dispose();
}
}
internal static class AdminClient
{
public static async Task<JsonObject> SendAsync(JsonObject request, int port, string tokenPath, CancellationToken cancellationToken = default)
{
var authenticated = (JsonObject)request.DeepClone();
authenticated["adminToken"] = AdminTokenStore.Load(tokenPath);
var encoded = JsonSerializer.SerializeToUtf8Bytes(authenticated);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false);
var stream = client.GetStream();
await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
using var line = new MemoryStream();
var one = new byte[1];
while (line.Length <= ProtocolConstants.MaxMessageBytes)
{
var count = await stream.ReadAsync(one, cancellationToken).ConfigureAwait(false);
if (count == 0) throw new IOException("Admin server closed the connection without a response.");
if (one[0] == (byte)'\n') break;
line.WriteByte(one[0]);
}
if (line.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Admin response exceeded the maximum size.");
return JsonNode.Parse(line.ToArray()) as JsonObject ?? throw new InvalidDataException("Admin response must be a JSON object.");
}
}
internal sealed class LanDiscoveryService : IAsyncDisposable
{
public const int DiscoveryPort = 7778;
public const string ProtocolName = "commonwealth-online";
private readonly AuthoritativeServer _server;
private readonly ServerOptions _options;
private readonly CancellationTokenSource _shutdown = new();
private UdpClient? _udp;
private Task? _task;
public LanDiscoveryService(AuthoritativeServer server, ServerOptions options) { _server = server; _options = options; }
public void Start()
{
_udp = new UdpClient(new IPEndPoint(IPAddress.Any, DiscoveryPort));
_udp.EnableBroadcast = true;
_task = Task.Run(() => RunAsync(_shutdown.Token));
_server.Log($"LAN discovery listening on UDP port {DiscoveryPort}");
}
private async Task RunAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
UdpReceiveResult result;
try { result = await _udp!.ReceiveAsync(cancellationToken).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch (SocketException) { continue; }
JsonObject? request;
try { request = JsonNode.Parse(result.Buffer) as JsonObject; } catch (JsonException) { continue; }
if (request is null || JsonHelpers.String(request["type"]) != "discover" || JsonHelpers.String(request["protocol"]) != ProtocolName) continue;
var core = _server.GetCoreStats();
var response = new JsonObject
{
["type"] = "discoverResponse", ["protocol"] = ProtocolName, ["version"] = 1, ["name"] = _options.ServerName,
["description"] = _options.ServerDescription, ["port"] = _options.Port, ["players"] = core["connectedClients"]?.DeepClone(), ["maxPlayers"] = _options.MaxPlayers
};
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
try { await _udp.SendAsync(bytes, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); } catch { }
}
}
public async ValueTask DisposeAsync()
{
_shutdown.Cancel();
_udp?.Dispose();
if (_task is not null) { try { await _task.ConfigureAwait(false); } catch { } }
_shutdown.Dispose();
}
}