- Polls the keymaster every 5 s for commands from the owner's launcher, runs only allowed commands (say, kick, ban, save, backup, restart, mods reload, status, players), sends results and the player list back - RemoteManagement in server.json / web panel settings (on by default) to turn it off Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
5.2 KiB
C#
113 lines
5.2 KiB
C#
using System.Net.Http.Json;
|
|
|
|
namespace MSCO.Server;
|
|
|
|
/// <summary>
|
|
/// Alle Server hängen am offiziellen Master (Keymaster + Serverliste). Die Adresse ist fest eingebaut,
|
|
/// damit niemand die Schlüsselprüfung über einen eigenen Master umgehen kann - nicht über server.json,
|
|
/// Umgebungsvariablen oder Startparameter änderbar.
|
|
/// </summary>
|
|
public static class MasterServer
|
|
{
|
|
public const string OfficialUrl = "https://msco.mbm.wtf";
|
|
public static string Url => OfficialUrl;
|
|
}
|
|
|
|
/// <summary>Spricht mit dem Master-Server: Lizenzschlüssel prüfen und Server in der Liste melden.</summary>
|
|
public sealed class MasterClient
|
|
{
|
|
public sealed record KeyInfo(bool Valid, string Owner, string Label, string Error)
|
|
{
|
|
/// <summary>Master war nicht erreichbar (Netzwerk, Proxy-Fehler) - dann später erneut versuchen.</summary>
|
|
public bool Unreachable { get; init; }
|
|
}
|
|
record HeartbeatBody(string Key, string Name, string Description, string Address, int Port, int Players, int MaxPlayers,
|
|
string Version, bool Public, bool RequireSteam, List<string> Tags, List<ModInfo> Mods);
|
|
|
|
/// <summary>Mod des Servers für die Serverliste (die Dateien selbst bekommen Spieler direkt vom Server).</summary>
|
|
public sealed record ModInfo(string Id, string Name, string Version);
|
|
|
|
readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
|
readonly string baseUrl;
|
|
|
|
public MasterClient(string masterUrl)
|
|
{
|
|
baseUrl = masterUrl.TrimEnd('/');
|
|
}
|
|
|
|
public async Task<KeyInfo> ValidateKeyAsync(string key)
|
|
{
|
|
try
|
|
{
|
|
var response = await http.PostAsJsonAsync(baseUrl + "/api/keys/validate", new { key });
|
|
// 5xx (z.B. 502 vom Reverse Proxy, Master gerade nicht da) = nicht erreichbar, nicht "ungültig"
|
|
if ((int)response.StatusCode >= 500)
|
|
return new KeyInfo(false, null, null, "HTTP " + (int)response.StatusCode) { Unreachable = true };
|
|
var info = await response.Content.ReadFromJsonAsync<KeyInfo>();
|
|
return info ?? new KeyInfo(false, null, null, "Empty response") { Unreachable = true };
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new KeyInfo(false, null, null, e.Message) { Unreachable = true };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Antwort auf den Heartbeat. Status: HTTP-Code, 0 = Master nicht erreichbar, 401 = Schlüssel ungültig.
|
|
/// Reachable: ob der Master den Server von außen erreicht (null = Prüfung läuft noch).
|
|
/// </summary>
|
|
public sealed record HeartbeatResult(int Status, bool? Reachable, bool Listed, string Address, int Port);
|
|
record HeartbeatReply(bool? Reachable, bool Listed, string Address, int Port);
|
|
|
|
public sealed record RemoteCommand(string Id, string Command, string By);
|
|
public sealed record RemoteResult(string Id, string Output);
|
|
public sealed record RemotePlayer(int Id, string Name);
|
|
record PollReply(List<RemoteCommand> Commands);
|
|
|
|
/// <summary>Befehle aus dem Launcher abholen (und Ergebnisse der letzten Befehle abliefern). null = Master nicht erreichbar.</summary>
|
|
public async Task<List<RemoteCommand>> PollAsync(string key, List<RemoteResult> results, List<RemotePlayer> players)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
|
var response = await http.PostAsJsonAsync(baseUrl + "/api/servers/poll", new { key, results, players }, cts.Token);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
var reply = await response.Content.ReadFromJsonAsync<PollReply>(cancellationToken: cts.Token);
|
|
return reply?.Commands ?? new List<RemoteCommand>();
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>Beim Beenden: sofort aus der Serverliste nehmen (sonst erst nach dem Heartbeat-Timeout).</summary>
|
|
public async Task UnregisterAsync(string key)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
await http.PostAsJsonAsync(baseUrl + "/api/servers/unregister", new { key }, cts.Token);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
public async Task<HeartbeatResult> HeartbeatAsync(ServerConfig config, int players, string version, List<ModInfo> mods = null)
|
|
{
|
|
try
|
|
{
|
|
var body = new HeartbeatBody(config.LicenseKey, config.Name, config.Description, config.PublicAddress, config.Port,
|
|
players, config.MaxPlayers, version, config.Public, config.RequireSteam,
|
|
ServerConfig.NormalizeTags(config.Tags, out _), mods ?? new List<ModInfo>());
|
|
var response = await http.PostAsJsonAsync(baseUrl + "/api/servers/heartbeat", body);
|
|
int status = (int)response.StatusCode;
|
|
if (!response.IsSuccessStatusCode) return new HeartbeatResult(status, null, false, null, 0);
|
|
|
|
HeartbeatReply reply = null;
|
|
try { reply = await response.Content.ReadFromJsonAsync<HeartbeatReply>(); } catch { }
|
|
return new HeartbeatResult(status, reply?.Reachable, reply?.Listed ?? false, reply?.Address, reply?.Port ?? 0);
|
|
}
|
|
catch
|
|
{
|
|
return new HeartbeatResult(0, null, false, null, 0);
|
|
}
|
|
}
|
|
}
|