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
+1 -1
View File
@@ -7,7 +7,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BSO.Server</RootNamespace> <RootNamespace>BSO.Server</RootNamespace>
<AssemblyName>BSO.Server</AssemblyName> <AssemblyName>BSO.Server</AssemblyName>
<Version>0.2.0</Version> <Version>0.3.0</Version>
<Product>BSO Dedicated Server</Product> <Product>BSO Dedicated Server</Product>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
<!-- Symbol der .exe (branding\make-assets.ps1) --> <!-- Symbol der .exe (branding\make-assets.ps1) -->
+36 -2
View File
@@ -11,7 +11,7 @@ namespace BSO.Server;
/// </summary> /// </summary>
public class GameServer 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 HelloTimeout = TimeSpan.FromSeconds(10);
static readonly TimeSpan SilenceTimeout = TimeSpan.FromSeconds(30); // Mod pingt alle 2 s static readonly TimeSpan SilenceTimeout = TimeSpan.FromSeconds(30); // Mod pingt alle 2 s
@@ -25,6 +25,7 @@ public class GameServer
public DateTime Connected, LastMessage; public DateTime Connected, LastMessage;
public PlayerState State; public PlayerState State;
public bool HasState; public bool HasState;
public string AccountId; // aus dem Spiel-Ticket (null mit --no-auth)
} }
readonly TcpListener listener; readonly TcpListener listener;
@@ -34,12 +35,21 @@ public class GameServer
public string Name { get; } public string Name { get; }
public int MaxPlayers { get; } public int MaxPlayers { get; }
public TestBot Bot { get; private set; } public TestBot Bot { get; private set; }
/// <summary>Fuer den Heartbeat (anderer Thread) - wird in Tick aktualisiert.</summary>
public int PlayerCount => playerCount;
volatile int playerCount;
/// <summary>Spiel-Tickets pruefen (Konto der BSO-Website)? Aus mit --no-auth (LAN, Tests).</summary>
public bool RequireAccount { get; set; } = true;
public MasterClient Master { get; set; }
public GameServer(int port, string name, int maxPlayers) public GameServer(int port, string name, int maxPlayers)
{ {
Name = name; Name = name;
MaxPlayers = maxPlayers; 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(); listener.Start();
Log($"BSO Server {Version} (Protokoll {Protocol.Version}) auf Port {port} - \"{name}\", max. {maxPlayers} Spieler"); 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); if (c.Conn.IsClosed) Remove(c);
} }
playerCount = Players().Count();
if (Bot != null && Bot.Tick(Players().Where(p => p.HasState).Select(p => p.State))) if (Bot != null && Bot.Tick(Players().Where(p => p.HasState).Select(p => p.State)))
Broadcast(MsgType.PlayerState, StatePayload(Bot.Id, Bot.State), except: 0); 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}"); Reject(c, $"Versionen passen nicht: Server Protokoll {Protocol.Version}, Mod {modVersion} hat Protokoll {protocol}");
return; 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) if (Players().Count() >= MaxPlayers)
{ {
Reject(c, $"Server ist voll ({MaxPlayers} Spieler)"); Reject(c, $"Server ist voll ({MaxPlayers} Spieler)");
+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 { }
}
}
+19 -6
View File
@@ -4,30 +4,42 @@ using System.Net.Sockets;
using BSO.Net; using BSO.Net;
using BSO.Server; 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) // BSO.Server --probe host[:port] (Test: verbindet sich wie die Mod und zeigt, was ankommt)
int port = Protocol.DefaultPort, max = 8; int port = Protocol.DefaultPort, max = 8;
string name = "BSO Server", probe = null; string name = "BSO Server", description = "", probe = null, publicAddress = null, master = MasterClient.DefaultUrl;
bool bot = false; bool bot = false, listed = true, auth = true;
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
{ {
switch (args[i]) switch (args[i])
{ {
case "--port": port = int.Parse(args[++i]); break; case "--port": port = int.Parse(args[++i]); break;
case "--name": name = args[++i]; break; case "--name": name = args[++i]; break;
case "--description": description = args[++i]; break;
case "--max": max = int.Parse(args[++i]); break; case "--max": max = int.Parse(args[++i]); break;
case "--bot": bot = true; 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; case "--probe": probe = args[++i]; break;
default: 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; return 1;
} }
} }
if (probe != null) return Probe(probe); 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(); if (bot) server.AddBot();
GameServer.Log("Befehle: help, status, bot, kick <id>, stop"); GameServer.Log("Befehle: help, status, bot, kick <id>, stop");
@@ -50,6 +62,7 @@ while (running)
} }
Thread.Sleep(5); Thread.Sleep(5);
} }
masterClient.Stop();
server.Stop(); server.Stop();
GameServer.Log("Server beendet"); GameServer.Log("Server beendet");
return 0; 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; } if (!tcp.ConnectAsync(hp[0], p).Wait(5000)) { Console.WriteLine("Keine Verbindung zu " + target); return 2; }
var conn = new Connection(tcp); var conn = new Connection(tcp);
conn.Send(MsgType.Info, null); 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()))); conn.Send(MsgType.Ping, Wire.Build(w => w.Write(Stopwatch.GetTimestamp())));
// Eigene Position, damit der Testbot losfaehrt // Eigene Position, damit der Testbot losfaehrt
var me = new PlayerState { Mode = PlayerMode.InBus, X = 70000, Y = 30000, Z = 760 }; var me = new PlayerState { Mode = PlayerMode.InBus, X = 70000, Y = 30000, Z = 760 };
+17
View File
@@ -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. 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 | | | Konsole | |
|---|---| |---|---|
| `status` | Spieler mit Position, Modus und Tempo | | `status` | Spieler mit Position, Modus und Tempo |
+2 -2
View File
@@ -10,13 +10,13 @@ namespace BSO.Net
public static class Protocol public static class Protocol
{ {
/// <summary>Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab.</summary> /// <summary>Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab.</summary>
public const int Version = 1; public const int Version = 2; // 2: Hello mit Spiel-Ticket
public const int DefaultPort = 7777; public const int DefaultPort = 7777;
} }
public enum MsgType : byte 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] Welcome = 2, // Server -> Client: int eigene ID, string Servername, int n, je [int ID, string Name]
Reject = 3, // Server -> Client: string Grund Reject = 3, // Server -> Client: string Grund
PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BSO.Net
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Inhalt lesen, ohne die Signatur zu pruefen. null = kein Ticket / kaputt.</summary>
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; }
}
/// <summary>Signatur pruefen (Server). Gueltig und nicht abgelaufen -> Ticket, sonst null mit Grund.</summary>
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, '='));
}
}
}