Security: admin rights only for Steam-verified SteamIDs; protocol test suite (Tests/)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,4 @@ obj/
|
||||
data/
|
||||
publish/
|
||||
.git/
|
||||
Tests/
|
||||
|
||||
+32
-11
@@ -27,6 +27,8 @@ public sealed class GameServer
|
||||
public ClientState State;
|
||||
public string Name = "?";
|
||||
public string SteamId;
|
||||
/// <summary>SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte.</summary>
|
||||
public bool SteamVerified;
|
||||
public bool IsAdmin;
|
||||
public Task<string> Verification;
|
||||
}
|
||||
@@ -53,8 +55,12 @@ public sealed class GameServer
|
||||
static readonly TimeSpan ClientTimeout = TimeSpan.FromSeconds(30);
|
||||
bool running = true;
|
||||
|
||||
public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName)
|
||||
/// <summary>Ohne Master-Server (nur für automatische Tests): keine Heartbeats, keine Serverliste.</summary>
|
||||
readonly bool offline;
|
||||
|
||||
public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName, bool offline = false)
|
||||
{
|
||||
this.offline = offline;
|
||||
this.ownerName = ownerName;
|
||||
this.config = config;
|
||||
this.configPath = configPath;
|
||||
@@ -109,6 +115,9 @@ public sealed class GameServer
|
||||
if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)");
|
||||
else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)");
|
||||
Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions");
|
||||
if (!steam.Enabled && config.Admins.Count > 0)
|
||||
Log("WARNING: Admins are disabled - without SteamWebApiKey anyone could pretend to be an admin. " +
|
||||
"Get a free key at https://steamcommunity.com/dev/apikey and put it into server.json (\"SteamWebApiKey\").");
|
||||
Log("Type 'help' for commands.");
|
||||
|
||||
nextHeartbeat = 0;
|
||||
@@ -126,8 +135,11 @@ public sealed class GameServer
|
||||
foreach (var c in clients.Values) c.Conn.Close("Server shutting down");
|
||||
listener.Stop();
|
||||
SaveWorld();
|
||||
master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste
|
||||
Log("Removed from the server list.");
|
||||
if (!offline)
|
||||
{
|
||||
master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste
|
||||
Log("Removed from the server list.");
|
||||
}
|
||||
Log("Server stopped.");
|
||||
}
|
||||
|
||||
@@ -196,7 +208,7 @@ public sealed class GameServer
|
||||
else if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
|
||||
else ReportReachability(result);
|
||||
}
|
||||
if (now >= nextHeartbeat && heartbeat == null)
|
||||
if (!offline && now >= nextHeartbeat && heartbeat == null)
|
||||
{
|
||||
nextHeartbeat = now + HeartbeatSeconds;
|
||||
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
|
||||
@@ -362,10 +374,15 @@ public sealed class GameServer
|
||||
if (error != null) { Reject(c, error); return; }
|
||||
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
||||
|
||||
// Gleicher Steam-Account nochmal verbunden: alte Verbindung trennen
|
||||
// Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige
|
||||
// SteamID behaupten - damit darf er weder Admin werden noch einen verbundenen Spieler verdrängen.
|
||||
c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:");
|
||||
foreach (var other in clients.Values)
|
||||
if (other != c && other.State == ClientState.Joined && other.SteamId == c.SteamId)
|
||||
other.Conn.Close("Joined from another location");
|
||||
{
|
||||
if (other == c || other.State != ClientState.Joined || other.SteamId != c.SteamId) continue;
|
||||
if (c.SteamVerified) other.Conn.Close("Joined from another location"); // echter Account: alte Verbindung trennen
|
||||
else { Reject(c, "A player with this identity is already connected"); return; }
|
||||
}
|
||||
|
||||
c.State = ClientState.Joined;
|
||||
var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList();
|
||||
@@ -379,7 +396,9 @@ public sealed class GameServer
|
||||
Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), c.Id);
|
||||
|
||||
// Admin? Nur Spieler mit (geprüfter) SteamID können Admin sein
|
||||
c.IsAdmin = !c.SteamId.StartsWith("name:") && config.Admins.Contains(c.SteamId);
|
||||
c.IsAdmin = c.SteamVerified && config.Admins.Contains(c.SteamId);
|
||||
if (!c.SteamVerified && config.Admins.Contains(c.SteamId))
|
||||
Log($"{c.Name} uses admin SteamID {c.SteamId}, but it cannot be verified (no SteamWebApiKey) - no admin rights");
|
||||
bool isAdmin = c.IsAdmin;
|
||||
c.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(isAdmin)));
|
||||
|
||||
@@ -462,7 +481,7 @@ public sealed class GameServer
|
||||
config.Admins.Remove(target);
|
||||
if (makeAdmin) config.Admins.Add(target);
|
||||
config.Save(configPath);
|
||||
foreach (var online in clients.Values.Where(x => x.SteamId == target))
|
||||
foreach (var online in clients.Values.Where(x => x.SteamId == target && x.SteamVerified))
|
||||
{
|
||||
online.IsAdmin = makeAdmin;
|
||||
online.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(makeAdmin)));
|
||||
@@ -475,9 +494,11 @@ public sealed class GameServer
|
||||
Log("Unknown player id (see 'players') - or use a SteamID64");
|
||||
break;
|
||||
}
|
||||
if (who.SteamId.StartsWith("name:"))
|
||||
if (!who.SteamVerified)
|
||||
{
|
||||
Log("Only players with Steam can be admins (their SteamID is verified)");
|
||||
Log(steam.Enabled
|
||||
? "Only players with Steam can be admins"
|
||||
: "Admins need a verified SteamID - set SteamWebApiKey in server.json (https://steamcommunity.com/dev/apikey)");
|
||||
break;
|
||||
}
|
||||
config.Admins.Remove(who.SteamId);
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -15,4 +15,10 @@
|
||||
<ApplicationIcon>logo.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Tests sind ein eigenes Projekt im Ordner Tests -->
|
||||
<Compile Remove="Tests/**" />
|
||||
<None Remove="Tests/**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -111,9 +111,9 @@ und neu starten.
|
||||
| `RequireSteam` | `true` = nur Spieler mit Steam; `false` = Non-Steam-Server (Spieler werden über den Namen erkannt) |
|
||||
| `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) |
|
||||
| `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird |
|
||||
| `SteamWebApiKey` | optional: Steam-Anmeldetickets der Spieler bei Steam prüfen |
|
||||
| `SteamWebApiKey` | Steam-Anmeldetickets der Spieler bei Steam prüfen – **nötig für Admins** (kostenlos: https://steamcommunity.com/dev/apikey) |
|
||||
| `BannedSteamIds` | gesperrte Spieler |
|
||||
| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen |
|
||||
| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen (nur mit `SteamWebApiKey`, siehe Sicherheit) |
|
||||
|
||||
Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvariablen (`MSCO_LICENSE_KEY`,
|
||||
`MSCO_NAME`, `MSCO_PORT`, `MSCO_MAX_PLAYERS`, `MSCO_PUBLIC`, `MSCO_REQUIRE_STEAM`, `MSCO_PUBLIC_ADDRESS`,
|
||||
@@ -187,6 +187,9 @@ einen aktuellen Spielstand nach `data/save/` legen und `msco reset-world` ausfü
|
||||
- `data/` enthält Lizenzschlüssel und API-Keys – nicht veröffentlichen (steht in `.gitignore`).
|
||||
- Der Container läuft nicht als root. Spielerdaten (Nachrichten, Objektzustände) werden in der Größe begrenzt,
|
||||
unbekannte oder zu große Nachrichten verworfen.
|
||||
- **Admins nur mit `SteamWebApiKey`:** Ohne Key prüft der Server die Steam-Tickets nicht – jeder könnte dann eine
|
||||
fremde SteamID (z.B. die eines Admins) angeben. Deshalb gibt es ohne Key keine Admin-Rechte (das Log warnt beim
|
||||
Start), und eine unbestätigte SteamID kann keinen verbundenen Spieler verdrängen.
|
||||
- Gebannte Spieler werden über ihre (bei Steam geprüfte) SteamID erkannt. Auf Non-Steam-Servern gibt es keine
|
||||
verlässliche Identität – dort sind Bans nur über den Namen möglich.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="C:\Users\nico\Desktop\MSCOMod\MSCO.Server\MSCO.Server.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,276 @@
|
||||
using System.Net.Sockets;
|
||||
using MSCO.Server;
|
||||
|
||||
// Protokoll-Tests gegen den echten Server-Code: startet einen Server offline (ohne Master, ohne Lizenz) auf
|
||||
// Port 17777 und spielt mehrere Clients durch - Beitreten, Ping, Weltzustand, Chat, Admin-Rechte, kaputte Daten.
|
||||
// Ausführen im Server-Ordner: dotnet run --project Tests
|
||||
const int Port = 17777;
|
||||
const string AdminId = "76561198910227878";
|
||||
string dir = Path.Combine(Path.GetTempPath(), "msco-servertest-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(Path.Combine(dir, "save"));
|
||||
// Platzhalter-Spielstand (der Inhalt ist für die Protokoll-Tests egal)
|
||||
foreach (var name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" })
|
||||
File.WriteAllText(Path.Combine(dir, "save", name), "test " + name);
|
||||
string configPath = Path.Combine(dir, "server.json");
|
||||
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } };
|
||||
config.Save(configPath);
|
||||
|
||||
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
|
||||
if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; }
|
||||
var serverThread = new Thread(server.Run) { IsBackground = true };
|
||||
serverThread.Start();
|
||||
Thread.Sleep(500);
|
||||
|
||||
int failed = 0, passed = 0;
|
||||
void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); }
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("== Join");
|
||||
var alice = await TestClient.Join("Alice", 0, null);
|
||||
Check(alice.Welcome != null, "Alice gets Welcome");
|
||||
Check(alice.Id > 0, "Alice has id " + alice.Id);
|
||||
Check(alice.AdminStatus == false, "Alice is not admin");
|
||||
Check(alice.SaveFiles == 5, "Alice received save files (" + alice.SaveFiles + ")");
|
||||
Check(alice.GotStartGame, "Alice got StartGame");
|
||||
|
||||
Console.WriteLine("== Ping");
|
||||
alice.Send(MsgType.Ping, BitConverter.GetBytes(12.5f));
|
||||
var pong = await alice.WaitFor(MsgType.Pong);
|
||||
Check(pong != null && BitConverter.ToSingle(pong, 0) == 12.5f, "Ping is echoed as Pong with the same timestamp");
|
||||
|
||||
Console.WriteLine("== Ready / snapshot");
|
||||
alice.Send(MsgType.Ready, Array.Empty<byte>());
|
||||
var snap = await alice.WaitFor(MsgType.WorldSnapshot);
|
||||
Check(snap != null, "WorldSnapshot received");
|
||||
var s0 = Snapshot.Parse(snap);
|
||||
Check(s0.Ok, "Snapshot parses completely (" + s0.Info + ")");
|
||||
|
||||
Console.WriteLine("== Second player, chat");
|
||||
var bob = await TestClient.Join("Bob", 0, null);
|
||||
Check(bob.Welcome != null, "Bob joins");
|
||||
var joined = await alice.WaitFor(MsgType.PlayerJoined);
|
||||
Check(joined != null, "Alice is told that Bob joined");
|
||||
alice.Send(MsgType.Chat, Wire.Build(w => w.Write("hello bob")));
|
||||
var chat = await bob.WaitFor(MsgType.Chat);
|
||||
Check(chat != null && new BinaryReader(new MemoryStream(chat)).ReadInt32() == alice.Id, "Bob receives Alice's chat with her id");
|
||||
Check(chat != null && ReadStringAfterSender(chat) == "hello bob", "Chat text arrives unchanged");
|
||||
|
||||
Console.WriteLine("== Object states (valid / invalid)");
|
||||
alice.Send(MsgType.ObjStates, ObjStates((111, 10, 1, 20, 0, 0, 0, 1), (222, float.NaN, 0, 0, 0, 0, 0, 1), (333, 0, -5000, 0, 0, 0, 0, 1), (444, 1, 1, 1, 0, 0, 0, 0)));
|
||||
var relay = await bob.WaitFor(MsgType.ObjStates);
|
||||
Check(relay != null, "Object states are relayed to Bob");
|
||||
alice.Send(MsgType.VarSync, Wire.Build(w => { w.Write(2); w.Write(42); w.Write((byte)1); w.Write(3.5f); w.Write(43); w.Write((byte)3); w.Write(true); }));
|
||||
await bob.WaitFor(MsgType.VarSync);
|
||||
var carol = await TestClient.Join("Carol", 0, null);
|
||||
carol.Send(MsgType.Ready, Array.Empty<byte>());
|
||||
var snap2 = Snapshot.Parse(await carol.WaitFor(MsgType.WorldSnapshot));
|
||||
Check(snap2.ObjectIds.Contains(111), "Valid object pose is stored");
|
||||
Check(!snap2.ObjectIds.Contains(222), "NaN pose is rejected");
|
||||
Check(!snap2.ObjectIds.Contains(333), "Pose far below the map is rejected");
|
||||
Check(!snap2.ObjectIds.Contains(444), "Invalid quaternion is rejected");
|
||||
Check(snap2.Vars == 2, "Vehicle values are stored and sent to new players (" + snap2.Vars + ")");
|
||||
|
||||
Console.WriteLine("== Server full");
|
||||
var eve = await TestClient.Join("Eve", 0, null);
|
||||
var dave = await TestClient.Join("Dave", 0, null, expectReject: true);
|
||||
Check(dave.RejectReason != null && dave.RejectReason.Contains("full"), "Player over the limit is rejected: " + dave.RejectReason);
|
||||
eve.Close();
|
||||
|
||||
Console.WriteLine("== Admin rights");
|
||||
alice.Send(MsgType.AdminListRequest, Array.Empty<byte>());
|
||||
var notAdmin = await alice.WaitFor(MsgType.AdminResult);
|
||||
Check(notAdmin != null && ReadString(notAdmin).Contains("not an admin"), "Non-admin cannot open the admin list");
|
||||
alice.Send(MsgType.AdminAction, Wire.Build(w => { w.Write((byte)1); w.Write(bob.Id); w.Write("x"); }));
|
||||
var kickTry = await alice.WaitFor(MsgType.AdminResult);
|
||||
Check(kickTry != null && ReadString(kickTry).Contains("not an admin"), "Non-admin cannot kick");
|
||||
carol.Close();
|
||||
await Task.Delay(300);
|
||||
|
||||
// Jemand gibt sich mit der SteamID des Admins aus (ohne Steam Web API Key wird das Ticket nicht geprüft)
|
||||
var faker = await TestClient.Join("Faker", ulong.Parse(AdminId), new byte[] { 1, 2, 3, 4 });
|
||||
Check(faker.Welcome != null, "Player with a claimed SteamID joins (tickets not verified without key)");
|
||||
Check(faker.AdminStatus == false, "Claimed (unverified) SteamID must NOT get admin rights");
|
||||
var faker2 = await TestClient.Join("Faker2", ulong.Parse(AdminId), new byte[] { 9 }, expectReject: true);
|
||||
Check(faker2.RejectReason != null && faker2.RejectReason.Contains("already connected"),
|
||||
"Second player with the same unverified SteamID cannot push out the first: " + faker2.RejectReason);
|
||||
Check(!faker.IsClosedByServer(), "The first player stays connected");
|
||||
faker.Close();
|
||||
await Task.Delay(300);
|
||||
|
||||
Console.WriteLine("== Robustness");
|
||||
var evil = await TestClient.Join("Evil", 0, null);
|
||||
evil.SendRaw(BitConverter.GetBytes(5 * 1024 * 1024)); // Paket, das 5 MB groß sein will
|
||||
await Task.Delay(500);
|
||||
Check(evil.IsClosedByServer(), "Oversized packet closes only that connection");
|
||||
var bob2 = await TestClient.Join("Bob2", 0, null);
|
||||
Check(bob2.Welcome != null, "Server still accepts players afterwards");
|
||||
bob2.Send(MsgType.VarSync, new byte[] { 1, 2 }); // kaputte Nachricht
|
||||
await Task.Delay(500);
|
||||
Check(bob2.IsClosedByServer(), "Malformed message closes only that connection");
|
||||
alice.Send(MsgType.Ping, BitConverter.GetBytes(1f));
|
||||
Check(await alice.WaitFor(MsgType.Pong) != null, "Other players are not affected");
|
||||
|
||||
Console.WriteLine("== Chat limits");
|
||||
alice.Send(MsgType.Chat, Wire.Build(w => w.Write(new string('x', 5000))));
|
||||
var longChat = await bob.WaitFor(MsgType.Chat, 800);
|
||||
Check(longChat == null, "Huge chat messages are not relayed");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
failed++;
|
||||
Console.WriteLine(" FAIL exception: " + e);
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n{passed} passed, {failed} failed");
|
||||
server.EnqueueCommand("stop");
|
||||
Thread.Sleep(300);
|
||||
try { Directory.Delete(dir, true); } catch { }
|
||||
return failed == 0 ? 0 : 1;
|
||||
|
||||
static string ReadString(byte[] p) => new BinaryReader(new MemoryStream(p)).ReadString();
|
||||
static string ReadStringAfterSender(byte[] p) { var r = new BinaryReader(new MemoryStream(p)); r.ReadInt32(); return r.ReadString(); }
|
||||
|
||||
static byte[] ObjStates(params (int id, float x, float y, float z, float qx, float qy, float qz, float qw)[] items) => Wire.Build(w =>
|
||||
{
|
||||
w.Write(items.Length);
|
||||
foreach (var i in items)
|
||||
{
|
||||
w.Write(i.id); w.Write(i.x); w.Write(i.y); w.Write(i.z);
|
||||
w.Write(i.qx); w.Write(i.qy); w.Write(i.qz); w.Write(i.qw);
|
||||
w.Write(0f); w.Write(0f); w.Write(0f);
|
||||
}
|
||||
});
|
||||
|
||||
sealed class Snapshot
|
||||
{
|
||||
public bool Ok;
|
||||
public string Info;
|
||||
public HashSet<int> ObjectIds = new();
|
||||
public int Vars = -1;
|
||||
|
||||
public static Snapshot Parse(byte[] data)
|
||||
{
|
||||
var s = new Snapshot();
|
||||
if (data == null) { s.Info = "no data"; return s; }
|
||||
try
|
||||
{
|
||||
var r = new BinaryReader(new MemoryStream(data));
|
||||
if (r.ReadBoolean()) { r.ReadSingle(); r.ReadSingle(); r.ReadSingle(); }
|
||||
int objects = r.ReadInt32();
|
||||
for (int i = 0; i < objects; i++) { s.ObjectIds.Add(r.ReadInt32()); for (int k = 0; k < 7; k++) r.ReadSingle(); }
|
||||
int events = r.ReadInt32();
|
||||
for (int i = 0; i < events; i++) r.ReadBytes(r.ReadInt32());
|
||||
float money = r.ReadSingle();
|
||||
int doors = r.ReadInt32();
|
||||
for (int i = 0; i < doors; i++) { r.ReadString(); r.ReadString(); }
|
||||
s.Vars = r.ReadInt32();
|
||||
for (int i = 0; i < s.Vars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); }
|
||||
s.Ok = r.BaseStream.Position == r.BaseStream.Length;
|
||||
s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars";
|
||||
}
|
||||
catch (Exception e) { s.Info = "parse error " + e.Message; }
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TestClient
|
||||
{
|
||||
const int Port = 17777;
|
||||
TcpClient tcp;
|
||||
NetworkStream stream;
|
||||
readonly List<(MsgType type, byte[] payload)> inbox = new();
|
||||
readonly object gate = new();
|
||||
bool closed;
|
||||
|
||||
public byte[] Welcome;
|
||||
public int Id;
|
||||
public bool? AdminStatus;
|
||||
public int SaveFiles;
|
||||
public bool GotStartGame;
|
||||
public string RejectReason;
|
||||
|
||||
public static async Task<TestClient> Join(string name, ulong steamId, byte[] ticket, bool expectReject = false)
|
||||
{
|
||||
var c = new TestClient { tcp = new TcpClient() };
|
||||
await c.tcp.ConnectAsync("127.0.0.1", Port);
|
||||
c.stream = c.tcp.GetStream();
|
||||
_ = Task.Run(c.ReadLoop);
|
||||
c.Send(MsgType.Hello, Wire.Build(w =>
|
||||
{
|
||||
w.Write("2.6");
|
||||
w.Write(name);
|
||||
w.Write(steamId);
|
||||
w.Write(ticket?.Length ?? 0);
|
||||
if (ticket != null) w.Write(ticket);
|
||||
}));
|
||||
if (expectReject)
|
||||
{
|
||||
var rej = await c.WaitFor(MsgType.Reject);
|
||||
if (rej != null) c.RejectReason = new BinaryReader(new MemoryStream(rej)).ReadString();
|
||||
return c;
|
||||
}
|
||||
c.Welcome = await c.WaitFor(MsgType.Welcome);
|
||||
if (c.Welcome != null) c.Id = BitConverter.ToInt32(c.Welcome, 0);
|
||||
var admin = await c.WaitFor(MsgType.AdminStatus);
|
||||
if (admin != null) c.AdminStatus = admin[0] != 0;
|
||||
await c.WaitFor(MsgType.SaveBegin);
|
||||
while (await c.WaitFor(MsgType.SaveFile, 1500) != null) c.SaveFiles++;
|
||||
c.GotStartGame = await c.WaitFor(MsgType.StartGame, 1500) != null || c.SaveFiles > 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
async Task ReadLoop()
|
||||
{
|
||||
var header = new byte[4];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await ReadExact(header, 4);
|
||||
int len = BitConverter.ToInt32(header, 0);
|
||||
var body = new byte[len];
|
||||
await ReadExact(body, len);
|
||||
lock (gate) inbox.Add(((MsgType)body[0], body[1..]));
|
||||
}
|
||||
}
|
||||
catch { closed = true; }
|
||||
}
|
||||
|
||||
async Task ReadExact(byte[] buf, int count)
|
||||
{
|
||||
int off = 0;
|
||||
while (off < count)
|
||||
{
|
||||
int n = await stream.ReadAsync(buf.AsMemory(off, count - off));
|
||||
if (n <= 0) throw new IOException("closed");
|
||||
off += n;
|
||||
}
|
||||
}
|
||||
|
||||
public void Send(MsgType type, byte[] payload)
|
||||
{
|
||||
byte[] frame = Wire.Frame(type, payload);
|
||||
try { stream.Write(frame, 0, frame.Length); } catch { closed = true; }
|
||||
}
|
||||
|
||||
public void SendRaw(byte[] data) { try { stream.Write(data, 0, data.Length); stream.Write(new byte[16], 0, 16); } catch { closed = true; } }
|
||||
|
||||
public async Task<byte[]> WaitFor(MsgType type, int timeoutMs = 3000)
|
||||
{
|
||||
var end = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
int i = inbox.FindIndex(m => m.type == type);
|
||||
if (i >= 0) { var p = inbox[i].payload; inbox.RemoveAt(i); return p; }
|
||||
}
|
||||
await Task.Delay(20);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsClosedByServer() => closed;
|
||||
public void Close() { try { tcp.Close(); } catch { } }
|
||||
}
|
||||
Reference in New Issue
Block a user