diff --git a/BSO.Server.csproj b/BSO.Server.csproj index 5ba3ea5..a8298b1 100644 --- a/BSO.Server.csproj +++ b/BSO.Server.csproj @@ -7,7 +7,7 @@ enable BSO.Server BSO.Server - 0.2.0 + 0.3.0 BSO Dedicated Server true diff --git a/GameServer.cs b/GameServer.cs index d7e0cbd..1ffa14c 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -11,7 +11,7 @@ namespace BSO.Server; /// public class GameServer { - public const string Version = "0.2.0"; + public const string Version = "0.3.0"; static readonly TimeSpan HelloTimeout = TimeSpan.FromSeconds(10); static readonly TimeSpan SilenceTimeout = TimeSpan.FromSeconds(30); // Mod pingt alle 2 s @@ -25,6 +25,7 @@ public class GameServer public DateTime Connected, LastMessage; public PlayerState State; public bool HasState; + public string AccountId; // aus dem Spiel-Ticket (null mit --no-auth) } readonly TcpListener listener; @@ -34,12 +35,21 @@ public class GameServer public string Name { get; } public int MaxPlayers { get; } public TestBot Bot { get; private set; } + /// Fuer den Heartbeat (anderer Thread) - wird in Tick aktualisiert. + public int PlayerCount => playerCount; + volatile int playerCount; + + /// Spiel-Tickets pruefen (Konto der BSO-Website)? Aus mit --no-auth (LAN, Tests). + public bool RequireAccount { get; set; } = true; + public MasterClient Master { get; set; } public GameServer(int port, string name, int maxPlayers) { Name = name; MaxPlayers = maxPlayers; - listener = new TcpListener(IPAddress.Any, port); + // IPv4 und IPv6 (die Website prueft die Erreichbarkeit ueber die Adresse, von der der Heartbeat kam - das kann IPv6 sein) + listener = new TcpListener(IPAddress.IPv6Any, port); + listener.Server.DualMode = true; listener.Start(); Log($"BSO Server {Version} (Protokoll {Protocol.Version}) auf Port {port} - \"{name}\", max. {maxPlayers} Spieler"); } @@ -76,6 +86,7 @@ public class GameServer if (c.Conn.IsClosed) Remove(c); } + playerCount = Players().Count(); if (Bot != null && Bot.Tick(Players().Where(p => p.HasState).Select(p => p.State))) Broadcast(MsgType.PlayerState, StatePayload(Bot.Id, Bot.State), except: 0); } @@ -105,6 +116,29 @@ public class GameServer Reject(c, $"Versionen passen nicht: Server Protokoll {Protocol.Version}, Mod {modVersion} hat Protokoll {protocol}"); return; } + // Ab Protokoll 2: Spiel-Ticket des Launchers - der Name kommt dann aus dem Konto, nicht vom Spieler + string ticketText = r.ReadString(); + if (RequireAccount) + { + if (Master?.TicketKey == null) + { + Reject(c, "Der Server kann gerade keine BSO-Konten pruefen (Website nicht erreichbar)"); + return; + } + Ticket ticket = Ticket.Verify(ticketText, Master.TicketKey, out string ticketError); + if (ticket == null) + { + Reject(c, ticketError); + return; + } + if (Players().Any(p => p.AccountId == ticket.AccountId)) + { + Reject(c, $"Das Konto {ticket.Username} ist schon auf diesem Server"); + return; + } + c.AccountId = ticket.AccountId; + name = Clean(ticket.Username, c.Id); + } if (Players().Count() >= MaxPlayers) { Reject(c, $"Server ist voll ({MaxPlayers} Spieler)"); diff --git a/MasterClient.cs b/MasterClient.cs new file mode 100644 index 0000000..522e037 --- /dev/null +++ b/MasterClient.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using BSO.Net; + +namespace BSO.Server; + +/// +/// Verbindung zur BSO-Website (wie MasterClient bei MSCO): jede Minute ein Heartbeat fuer die Serverliste, beim Beenden +/// abmelden. Ausserdem der oeffentliche Ticket-Schluessel, mit dem der Server die Konten der Spieler prueft - er wird in +/// ticket-key.xml zwischengespeichert, damit ein Neustart auch ohne Website klappt. +/// +public class MasterClient +{ + public const string DefaultUrl = "https://bso.mbm.wtf"; + static readonly TimeSpan Interval = TimeSpan.FromSeconds(60); + + readonly string url; + readonly GameServer server; + readonly int port; + readonly bool listed; + readonly string publicAddress, description; + readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(15) }; + readonly CancellationTokenSource stop = new(); + bool? lastReachable; + + public RSA TicketKey { get; private set; } + static string KeyFile => Path.Combine(AppContext.BaseDirectory, "ticket-key.xml"); + + public MasterClient(string url, GameServer server, int port, bool listed, string publicAddress, string description) + { + this.url = url.TrimEnd('/'); + this.server = server; + this.port = port; + this.listed = listed; + this.publicAddress = publicAddress; + this.description = description; + http.DefaultRequestHeaders.UserAgent.ParseAdd("BSO-Server/" + GameServer.Version); + } + + /// Ticket-Schluessel von der Website - sonst der zuletzt gespeicherte. + public async Task LoadTicketKeyAsync() + { + string xml = null; + try + { + var r = await http.GetFromJsonAsync(url + "/api/auth/ticket-key"); + xml = r.GetProperty("key").GetString(); + File.WriteAllText(KeyFile, xml); + GameServer.Log("Ticket-Schluessel von " + url + " geladen - Spieler brauchen ein BSO-Konto"); + } + catch (Exception e) + { + if (File.Exists(KeyFile)) + { + xml = File.ReadAllText(KeyFile); + GameServer.Log($"Website nicht erreichbar ({e.Message}) - nehme gespeicherten Ticket-Schluessel"); + } + else GameServer.Log($"Website nicht erreichbar ({e.Message}) und kein gespeicherter Ticket-Schluessel - niemand kann beitreten (zum Testen: --no-auth)"); + } + if (xml == null) return; + var rsa = RSA.Create(); + rsa.FromXmlString(xml); + TicketKey = rsa; + } + + public void Start() + { + if (!listed) { GameServer.Log("Nicht in der Serverliste (--private) - Beitritt nur per Adresse"); return; } + _ = Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + await Heartbeat(); + try { await Task.Delay(Interval, stop.Token); } catch (TaskCanceledException) { } + } + }); + } + + async Task Heartbeat() + { + try + { + var res = await http.PostAsJsonAsync(url + "/api/servers/heartbeat", new + { + name = server.Name, + description, + port, + players = server.PlayerCount, + maxPlayers = server.MaxPlayers, + version = GameServer.Version, + protocol = Protocol.Version, + publicAddress, + @public = true + }); + var r = await res.Content.ReadFromJsonAsync(); + if (!res.IsSuccessStatusCode) + { + GameServer.Log("Serverliste: " + (r.TryGetProperty("error", out var err) ? err.GetString() : res.StatusCode.ToString())); + return; + } + bool? reachable = r.GetProperty("reachable").ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, _ => null }; + if (reachable != lastReachable && reachable != null) + GameServer.Log(reachable == true + ? $"In der Serverliste als {r.GetProperty("address").GetString()}" + : $"Nicht in der Serverliste: die Website erreicht {r.GetProperty("address").GetString()} nicht - Port {port}/TCP freigeben (oder --public-address)"); + lastReachable = reachable ?? lastReachable; + } + catch (Exception e) + { + GameServer.Log("Serverliste nicht erreichbar: " + e.Message); + } + } + + public void Stop() + { + stop.Cancel(); + if (!listed) return; + try { http.PostAsJsonAsync(url + "/api/servers/unregister", new { port, publicAddress }).Wait(3000); } catch { } + } +} diff --git a/Program.cs b/Program.cs index f931485..870797d 100644 --- a/Program.cs +++ b/Program.cs @@ -4,30 +4,42 @@ using System.Net.Sockets; using BSO.Net; using BSO.Server; -// BSO.Server [--port 7777] [--name "Mein Server"] [--max 8] [--bot] +// BSO.Server [--port 7777] [--name "Mein Server"] [--description "..."] [--max 8] [--bot] +// [--private] [--public-address host] [--master https://bso.mbm.wtf] [--no-auth] // BSO.Server --probe host[:port] (Test: verbindet sich wie die Mod und zeigt, was ankommt) int port = Protocol.DefaultPort, max = 8; -string name = "BSO Server", probe = null; -bool bot = false; +string name = "BSO Server", description = "", probe = null, publicAddress = null, master = MasterClient.DefaultUrl; +bool bot = false, listed = true, auth = true; for (int i = 0; i < args.Length; i++) { switch (args[i]) { case "--port": port = int.Parse(args[++i]); break; case "--name": name = args[++i]; break; + case "--description": description = args[++i]; break; case "--max": max = int.Parse(args[++i]); break; case "--bot": bot = true; break; + case "--private": listed = false; break; // nicht in der Serverliste + case "--public-address": publicAddress = args[++i]; break; // Adresse fuer die Liste (hinter NAT/Proxy) + case "--master": master = args[++i]; break; // andere Website (Tests: http://localhost:5080) + case "--no-auth": auth = false; break; // ohne BSO-Konto (LAN, Tests) case "--probe": probe = args[++i]; break; default: - Console.WriteLine("BSO.Server [--port 7777] [--name \"Name\"] [--max 8] [--bot] | --probe host[:port]"); + Console.WriteLine("BSO.Server [--port 7777] [--name \"Name\"] [--description \"...\"] [--max 8] [--bot] [--private]\n" + + " [--public-address host] [--master url] [--no-auth] | --probe host[:port]"); return 1; } } if (probe != null) return Probe(probe); -var server = new GameServer(port, name, max); +var server = new GameServer(port, name, max) { RequireAccount = auth }; +var masterClient = new MasterClient(master, server, port, listed, publicAddress, description); +server.Master = masterClient; +if (auth) masterClient.LoadTicketKeyAsync().Wait(); +else GameServer.Log("--no-auth: Spieler brauchen kein BSO-Konto, Namen sind frei waehlbar"); +masterClient.Start(); if (bot) server.AddBot(); GameServer.Log("Befehle: help, status, bot, kick , stop"); @@ -50,6 +62,7 @@ while (running) } Thread.Sleep(5); } +masterClient.Stop(); server.Stop(); GameServer.Log("Server beendet"); return 0; @@ -63,7 +76,7 @@ static int Probe(string target) if (!tcp.ConnectAsync(hp[0], p).Wait(5000)) { Console.WriteLine("Keine Verbindung zu " + target); return 2; } var conn = new Connection(tcp); conn.Send(MsgType.Info, null); - conn.Send(MsgType.Hello, Wire.Build(w => { w.Write(Protocol.Version); w.Write("probe"); w.Write("Probe"); })); + conn.Send(MsgType.Hello, Wire.Build(w => { w.Write(Protocol.Version); w.Write("probe"); w.Write("Probe"); w.Write(Environment.GetEnvironmentVariable("BSO_PROBE_TICKET") ?? ""); })); conn.Send(MsgType.Ping, Wire.Build(w => w.Write(Stopwatch.GetTimestamp()))); // Eigene Position, damit der Testbot losfaehrt var me = new PlayerState { Mode = PlayerMode.InBus, X = 70000, Y = 30000, Z = 760 }; diff --git a/README.md b/README.md index 6fd1fde..3dfc9c4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,23 @@ BSO.Server.exe --probe host[:port] # Test: verbindet sich wi Für Spieler von außen Port **7777/TCP** im Router/Firewall freigeben. +| Option | | +|---|---| +| `--name`, `--description` | Anzeige im Server-Browser | +| `--max` | Maximale Spielerzahl (Standard 8) | +| `--private` | Nicht in der Serverliste – Beitritt nur per Adresse | +| `--public-address host` | Adresse für die Serverliste (hinter NAT/Proxy oder wenn die Website im selben Netz läuft) | +| `--master url` | andere Website (Standard `https://bso.mbm.wtf`, Tests: `http://localhost:5080`) | +| `--no-auth` | Spieler brauchen kein BSO-Konto (LAN, Tests) | +| `--bot` | Testbot | + +**Serverliste:** Der Server meldet sich jede Minute bei der Website, beim `stop` ab. Gelistet wird er erst, wenn die +Website ihn von außen erreicht (sie fragt ihn wie die Mod ab) – das Log sagt, ob es geklappt hat. + +**BSO-Konto:** Spieler brauchen ein Spiel-Ticket aus dem Launcher (signiert von der Website, 24 h gültig). Der Server +prüft die Signatur mit dem öffentlichen Schlüssel der Website (`ticket-key.xml` wird zwischengespeichert), der Name +im Spiel kommt aus dem Konto. Ein Konto kann nur einmal gleichzeitig auf einem Server sein. + | Konsole | | |---|---| | `status` | Spieler mit Position, Modus und Tempo | diff --git a/Shared/Protocol.cs b/Shared/Protocol.cs index f47b0c0..cb13710 100644 --- a/Shared/Protocol.cs +++ b/Shared/Protocol.cs @@ -10,13 +10,13 @@ namespace BSO.Net public static class Protocol { /// Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab. - public const int Version = 1; + public const int Version = 2; // 2: Hello mit Spiel-Ticket public const int DefaultPort = 7777; } public enum MsgType : byte { - Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name + Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name, string Spiel-Ticket (vom Launcher) Welcome = 2, // Server -> Client: int eigene ID, string Servername, int n, je [int ID, string Name] Reject = 3, // Server -> Client: string Grund PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState diff --git a/Shared/Ticket.cs b/Shared/Ticket.cs new file mode 100644 index 0000000..90093dc --- /dev/null +++ b/Shared/Ticket.cs @@ -0,0 +1,62 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace BSO.Net +{ + /// + /// Spiel-Ticket der BSO-Website: base64url(JSON {u, id, iat, exp}) + "." + base64url(RSA-SHA256-Signatur). Der Launcher + /// holt es beim Spielen und schreibt es in BSO.json, die Mod schickt es im Hello mit. Die Mod liest es nur (Name, Ablauf), + /// pruefen kann es nur der Server mit dem oeffentlichen Schluessel der Website (Verify) - wer die Mod veraendert, kommt so + /// trotzdem nicht ohne gueltiges Konto auf einen Server. + /// + public sealed class Ticket + { + public string Username { get; private set; } + public string AccountId { get; private set; } + public DateTime Expires { get; private set; } + public bool Expired => DateTime.UtcNow >= Expires; + + /// Inhalt lesen, ohne die Signatur zu pruefen. null = kein Ticket / kaputt. + public static Ticket Read(string ticket) + { + try + { + if (string.IsNullOrEmpty(ticket) || ticket.Length > 4096) return null; + string[] parts = ticket.Split('.'); + if (parts.Length != 2) return null; + using var doc = JsonDocument.Parse(FromB64(parts[0])); + var r = doc.RootElement; + return new Ticket + { + Username = r.GetProperty("u").GetString(), + AccountId = r.GetProperty("id").GetString(), + Expires = DateTimeOffset.FromUnixTimeSeconds(r.GetProperty("exp").GetInt64()).UtcDateTime + }; + } + catch { return null; } + } + + /// Signatur pruefen (Server). Gueltig und nicht abgelaufen -> Ticket, sonst null mit Grund. + public static Ticket Verify(string ticket, RSA publicKey, out string error) + { + error = null; + Ticket t = Read(ticket); + if (t == null) { error = "Kein gueltiges BSO-Konto - bitte ueber den BSO Launcher starten"; return null; } + string[] parts = ticket.Split('.'); + bool ok; + try { ok = publicKey.VerifyData(Encoding.ASCII.GetBytes(parts[0]), FromB64(parts[1]), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } + catch { ok = false; } + if (!ok) { error = "Ungueltiges Spiel-Ticket - bitte ueber den BSO Launcher starten"; return null; } + if (t.Expired) { error = "Spiel-Ticket abgelaufen - bitte im BSO Launcher neu auf SPIELEN druecken"; return null; } + return t; + } + + static byte[] FromB64(string s) + { + s = s.Replace('-', '+').Replace('_', '/'); + return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=')); + } + } +}