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:
+1
-1
@@ -7,7 +7,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>BSO.Server</RootNamespace>
|
||||
<AssemblyName>BSO.Server</AssemblyName>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.3.0</Version>
|
||||
<Product>BSO Dedicated Server</Product>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Symbol der .exe (branding\make-assets.ps1) -->
|
||||
|
||||
+36
-2
@@ -11,7 +11,7 @@ namespace BSO.Server;
|
||||
/// </summary>
|
||||
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; }
|
||||
/// <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)
|
||||
{
|
||||
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)");
|
||||
|
||||
+121
@@ -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
@@ -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 <id>, 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 };
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@ namespace BSO.Net
|
||||
public static class Protocol
|
||||
{
|
||||
/// <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 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
|
||||
|
||||
@@ -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, '='));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user