0.3.0: Serverliste und BSO-Konten

- Heartbeat an die Website, Abmelden beim Beenden (--private, --public-address, --description, --master)
- Spiel-Tickets pruefen (Signatur der Website), Name aus dem Konto, --no-auth fuer LAN
- IPv4 und IPv6, Protokoll 2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:10:51 +01:00
co-authored by Claude Opus 5.5
parent cda31cd070
commit 4e333c8250
7 changed files with 258 additions and 11 deletions
+121
View File
@@ -0,0 +1,121 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using BSO.Net;
namespace BSO.Server;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Ticket-Schluessel von der Website - sonst der zuletzt gespeicherte.</summary>
public async Task LoadTicketKeyAsync()
{
string xml = null;
try
{
var r = await http.GetFromJsonAsync<JsonElement>(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<JsonElement>();
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 { }
}
}