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