312 lines
14 KiB
C#
312 lines
14 KiB
C#
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();
|
|
}
|
|
}
|