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, '='));
+ }
+ }
+}