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 { } } }