diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c6be4d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +bin/ +obj/ +data/ +publish/ +.git/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a53975 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +data/ +*.user +.vs/ +publish/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07f3d3e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# MSCO dedizierter Server +# docker build -t msco-server MSCO.Server +# docker run -it -p 7777:7777 -v msco-server-data:/data msco-server +# Daten (server.json, save/, world.json) liegen im Volume /data. + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/runtime:10.0 +WORKDIR /app +COPY --from=build /app . +ENV MSCO_DATA=/data +# Befehle an den laufenden Server: docker exec msco-server msco players +RUN printf '#!/bin/sh\nexec dotnet /app/MSCO.Server.dll --cmd "$@"\n' > /usr/local/bin/msco && chmod +x /usr/local/bin/msco +VOLUME /data +EXPOSE 7777/tcp +# docker stop schickt SIGTERM -> Server speichert den Weltzustand +STOPSIGNAL SIGTERM +ENTRYPOINT ["dotnet", "MSCO.Server.dll"] diff --git a/GameServer.cs b/GameServer.cs new file mode 100644 index 0000000..987b1fa --- /dev/null +++ b/GameServer.cs @@ -0,0 +1,524 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace MSCO.Server; + +/// +/// Dedizierter Server: Verteiler zwischen den Spielern (wie der Host bei P2P, aber ohne eigenes Spiel), +/// hält den Spielstand und den Weltzustand und schickt beides an neue Spieler. +/// Die Physik simuliert weiterhin der Spieler, der ein Objekt gerade besitzt. +/// +public sealed class GameServer +{ + public const string ModVersion = "2.6"; // muss mit MSCO.ModVersion übereinstimmen + public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ... + + static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }; + const double HeartbeatSeconds = 30; + + enum ClientState { New, Verifying, Joined } + + sealed class Client + { + public int Id; + public Connection Conn; + public ClientState State; + public string Name = "?"; + public string SteamId; + public bool IsAdmin; + public Task Verification; + } + + readonly ServerConfig config; + readonly string configPath; + readonly string ownerName; // Besitzer des Lizenzschlüssels (Server-Profil im Browser) + readonly string savePath; + readonly string worldPath; + readonly MasterClient master; + readonly SteamVerifier steam; + readonly WorldState world; + readonly Dictionary clients = new(); + readonly ConcurrentQueue commands = new(); + readonly Stopwatch clock = Stopwatch.StartNew(); + + TcpListener listener; + List> saveFiles = new(); + int nextId = 1; + byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand + double nextAutosave, nextHeartbeat; + Task heartbeat; + bool? lastReachable; + static readonly TimeSpan ClientTimeout = TimeSpan.FromSeconds(30); + bool running = true; + + public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName) + { + this.ownerName = ownerName; + this.config = config; + this.configPath = configPath; + savePath = Path.Combine(dataDir, "save"); + worldPath = Path.Combine(dataDir, "world.json"); + master = new MasterClient(MasterServer.Url); + steam = new SteamVerifier(config.SteamWebApiKey); + world = WorldState.Load(worldPath); + } + + public void EnqueueCommand(string line) => commands.Enqueue(line); + + int JoinedCount => clients.Values.Count(c => c.State == ClientState.Joined); + + // ---------------------------------------------------------------- Start / Schleife + + public bool LoadSave() + { + saveFiles = new List>(); + Directory.CreateDirectory(savePath); + foreach (string name in SaveFiles) + { + string file = Path.Combine(savePath, name); + if (File.Exists(file)) saveFiles.Add(new(name, File.ReadAllBytes(file))); + } + if (saveFiles.All(f => f.Key != "defaultES2File.txt")) + { + Log("No save game found in " + savePath); + Log("Copy your My Summer Car save files there, or start the server with --import-save."); + return false; + } + Log("Loaded save game (" + saveFiles.Count + " files, " + saveFiles.Sum(f => f.Value.Length) / 1024 + " KB)"); + return true; + } + + public void Run() + { + listener = new TcpListener(IPAddress.Any, config.Port); + listener.Start(); + Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)"); + if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)"); + else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)"); + Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions"); + Log("Type 'help' for commands."); + + nextHeartbeat = 0; + nextAutosave = config.AutosaveSeconds; + + while (running) + { + AcceptClients(); + PollClients(); + RunCommands(); + Timers(); + Thread.Sleep(2); + } + + foreach (var c in clients.Values) c.Conn.Close("Server shutting down"); + listener.Stop(); + SaveWorld(); + master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste + Log("Removed from the server list."); + Log("Server stopped."); + } + + void AcceptClients() + { + while (listener.Pending()) + { + var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New }; + clients[c.Id] = c; + } + } + + void PollClients() + { + List closed = null; + foreach (var c in clients.Values.ToList()) + { + foreach (var m in c.Conn.Poll()) + { + try { Handle(c, m); } + catch (Exception e) { c.Conn.Close("Bad message: " + e.Message); } + } + + if (c.State == ClientState.Verifying && c.Verification.IsCompleted) FinishJoin(c); + + // Abgebrochene Verbindungen (Absturz, Netz weg) melden sich nicht ab - ohne Lebenszeichen raus, + // sonst zählen sie ewig als Spieler mit + if (!c.Conn.IsClosed && DateTime.UtcNow - c.Conn.LastReceiveUtc > ClientTimeout) + c.Conn.Close("Timed out"); + + if (c.Conn.IsClosed) (closed ??= new()).Add(c); + } + if (closed == null) return; + + foreach (var c in closed) + { + clients.Remove(c.Id); + if (c.State != ClientState.Joined) continue; + Log($"{c.Name} left ({c.Conn.CloseReason})"); + Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(c.Id))); + HeartbeatSoon(); + } + } + + /// Spielerzahl hat sich geändert: gleich beim Master melden statt erst in 30 s. + void HeartbeatSoon() => nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 1); + + void Timers() + { + double now = clock.Elapsed.TotalSeconds; + if (now >= nextAutosave) + { + nextAutosave = now + config.AutosaveSeconds; + if (world.Dirty) SaveWorld(); + } + + if (heartbeat != null && heartbeat.IsCompleted) + { + var result = heartbeat.Result; + heartbeat = null; + if (result.Status == 401 || result.Status == 403) + { + Log("License key was rejected by the master server - shutting down."); + running = false; + } + else if (result.Status == 0) Log("Master server not reachable - server is not listed right now."); + else ReportReachability(result); + } + if (now >= nextHeartbeat && heartbeat == null) + { + nextHeartbeat = now + HeartbeatSeconds; + heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion); + } + } + + /// Erreichbarkeit laut Master nur bei Änderung ausgeben. + void ReportReachability(MasterClient.HeartbeatResult r) + { + // Prüfung läuft noch: in ein paar Sekunden nachfragen statt erst in 30 s + if (r.Reachable == null) nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 6); + if (r.Reachable == null || r.Reachable == lastReachable) return; + lastReachable = r.Reachable; + if (r.Reachable == true) + Log($"Reachable from the internet at {r.Address}:{r.Port}" + (r.Listed ? " - listed in the server browser" : " (not public)")); + else + Log($"NOT reachable from the internet at {r.Address}:{r.Port} - not listed! Forward TCP port {r.Port} " + + "in your router/firewall, or set PublicAddress / MSCO_PUBLIC_ADDRESS if the address is wrong."); + } + + // ---------------------------------------------------------------- Nachrichten + + static bool IsGameMessage(MsgType t) => + t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent + or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync; + + /// Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients). + int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min(); + + void Handle(Client c, Message m) + { + switch (m.Type) + { + case MsgType.Info: + // Server-Browser: Infos + Ping, danach Verbindung schließen + c.Conn.Send(MsgType.InfoReply, Wire.Build(w => + { + w.Write(config.Name); + w.Write(config.Description ?? ""); + w.Write(JoinedCount); + w.Write(config.MaxPlayers); + w.Write(ModVersion); + // Erweiterung für das Server-Profil im Browser (ältere Mods lesen hier nicht weiter) + w.Write(config.RequireSteam); + w.Write(ownerName ?? ""); + w.Write((int)clock.Elapsed.TotalSeconds); + var names = clients.Values.Where(x => x.State == ClientState.Joined).Select(x => x.Name).ToList(); + w.Write(names.Count); + foreach (string n in names) w.Write(n); + })); + c.Conn.Close("Info query"); + return; + + case MsgType.Hello: + if (c.State == ClientState.New) BeginJoin(c, m.Reader()); + return; + + case MsgType.Ready: + if (c.State != ClientState.Joined) return; + c.Conn.Send(MsgType.WorldSnapshot, world.BuildSnapshot(c.SteamId)); + // Letzter Stand von Uhrzeit und Wetter, damit der Neue nicht bis zum nächsten Update warten muss + if (lastWorldTime != null) c.Conn.Send(MsgType.WorldTime, Wire.WithSender(ServerId, lastWorldTime)); + Log($"{c.Name} is in the game (sent {world.Objects.Count} objects, {world.Events.Count} actions)"); + return; + + case MsgType.Ping: + return; + + case MsgType.AdminListRequest: + if (c.State != ClientState.Joined) return; + if (!c.IsAdmin) { c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write("You are not an admin on this server"))); return; } + var joinedPlayers = clients.Values.Where(x => x.State == ClientState.Joined).ToList(); + c.Conn.Send(MsgType.AdminList, Wire.Build(w => + { + w.Write(joinedPlayers.Count); + foreach (var p in joinedPlayers) { w.Write(p.Id); w.Write(p.Name); w.Write(p.SteamId); w.Write(p.IsAdmin); } + })); + return; + + case MsgType.AdminAction: + { + if (c.State != ClientState.Joined) return; + var r = m.Reader(); + byte action = r.ReadByte(); + int targetId = r.ReadInt32(); + string reason = r.ReadString(); + string result; + if (!c.IsAdmin) result = "You are not an admin on this server"; + else if (!clients.TryGetValue(targetId, out var target) || target.State != ClientState.Joined) result = "Player not found"; + else if (target.IsAdmin && target != c) result = "You cannot kick or ban another admin"; + else result = KickOrBan(target, action == 2, reason, c.Name); + c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(result))); + return; + } + } + + if (c.State != ClientState.Joined || !IsGameMessage(m.Type)) return; + + // Weltzustand mitschreiben + switch (m.Type) + { + case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break; + case MsgType.ObjRelease: world.ApplyObjRelease(m.Payload); break; + case MsgType.FsmEvent: world.AddFsmEvent(m.Payload); break; + case MsgType.Player: world.ApplyPlayer(c.SteamId, m.Payload); break; + case MsgType.MoneyDelta: world.ApplyMoney(m.Payload); break; + case MsgType.DoorState: world.ApplyDoor(m.Payload); break; + case MsgType.VarSync: world.ApplyVars(m.Payload); break; + case MsgType.Chat: + if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten + Log($"[chat] {c.Name}: {m.Reader().ReadString()}"); + break; + case MsgType.WorldTime: + if (c.Id != WorldAuthorityId) return; // nur einer gibt Uhrzeit/Wetter vor + lastWorldTime = m.Payload; + break; + } + + // an alle anderen weiterleiten, mit Absender + Broadcast(m.Type, Wire.WithSender(c.Id, m.Payload), c.Id); + } + + void BeginJoin(Client c, BinaryReader r) + { + string version = r.ReadString(); + c.Name = r.ReadString(); + ulong steamId = 0; + byte[] ticket = null; + if (r.BaseStream.Position < r.BaseStream.Length) + { + steamId = r.ReadUInt64(); + ticket = r.ReadBytes(r.ReadInt32()); + } + c.Name = string.IsNullOrWhiteSpace(c.Name) ? "Player" : c.Name.Trim(); + if (c.Name.Length > 24) c.Name = c.Name[..24]; + // Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren) + c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant(); + + // Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken + if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly"); + if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; } + if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } + + c.State = ClientState.Verifying; + c.Verification = steam.VerifyAsync(steamId, ticket, config.RequireSteam); + } + + void FinishJoin(Client c) + { + string error = c.Verification.Result; + if (error != null) { Reject(c, error); return; } + if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } + + // Gleicher Steam-Account nochmal verbunden: alte Verbindung trennen + foreach (var other in clients.Values) + if (other != c && other.State == ClientState.Joined && other.SteamId == c.SteamId) + other.Conn.Close("Joined from another location"); + + c.State = ClientState.Joined; + var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList(); + c.Conn.Send(MsgType.Welcome, Wire.Build(w => + { + w.Write(c.Id); + w.Write(true); // dedizierter Server + w.Write(joined.Count); + foreach (var p in joined) { w.Write(p.Id); w.Write(p.Name); } + })); + Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), c.Id); + + // Admin? Nur Spieler mit (geprüfter) SteamID können Admin sein + c.IsAdmin = !c.SteamId.StartsWith("name:") && config.Admins.Contains(c.SteamId); + bool isAdmin = c.IsAdmin; + c.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(isAdmin))); + + // Spielstand schicken; nach dem Laden fragt der Client mit Ready nach dem Weltzustand + c.Conn.Send(MsgType.SaveBegin); + foreach (var f in saveFiles) + c.Conn.Send(MsgType.SaveFile, Wire.Build(w => { w.Write(f.Key); w.Write(f.Value.Length); w.Write(f.Value); })); + c.Conn.Send(MsgType.StartGame, Wire.Build(w => w.Write(false))); + + Log($"{c.Name} joined (SteamID {c.SteamId}, {c.Conn.RemoteAddress}) - {JoinedCount}/{config.MaxPlayers}"); + HeartbeatSoon(); + } + + void Reject(Client c, string reason) + { + c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(reason))); + c.Conn.Close(reason); + Log($"Rejected {c.Name} ({c.Conn.RemoteAddress}): {reason}"); + } + + /// Spieler kicken oder bannen (Konsole oder Admin im Spiel); alle sehen es im Chat. + string KickOrBan(Client target, bool ban, string reason, string by) + { + reason = (reason ?? "").Trim(); + if (reason.Length > 100) reason = reason[..100]; + if (ban && !config.BannedSteamIds.Contains(target.SteamId)) + { + config.BannedSteamIds.Add(target.SteamId); + config.Save(configPath); + } + string what = ban ? "banned" : "kicked"; + ServerChat($"{target.Name} was {what}" + (reason.Length > 0 ? $" ({reason})" : "")); + Reject(target, $"You were {what} by {by}" + (reason.Length > 0 ? $": {reason}" : "")); + Log($"{target.Name} was {what} by {by}" + (reason.Length > 0 ? $": {reason}" : "")); + return $"{target.Name} was {what}"; + } + + /// Chatnachricht vom Server an alle (Absender-ID 0 = "Server"). + void ServerChat(string text) + { + Broadcast(MsgType.Chat, Wire.WithSender(ServerId, Wire.Build(w => w.Write(text)))); + } + + void Broadcast(MsgType type, byte[] payload, int except = -1) + { + foreach (var c in clients.Values) + if (c.Id != except && c.State == ClientState.Joined) c.Conn.Send(type, payload); + } + + // ---------------------------------------------------------------- Konsole + + void RunCommands() + { + while (commands.TryDequeue(out string line)) + { + // BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren + string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (args.Length == 0) continue; + switch (args[0].ToLowerInvariant()) + { + case "help": + Log("status | players | kick [reason] | ban [reason] | say | op | deop | admins | save | reset-world | reload-save | stop"); + break; + case "say": + if (args.Length < 2) { Log("say "); break; } + ServerChat(args[1]); + Log("[chat] Server: " + args[1]); + break; + case "admins": + Log("Admins: " + (config.Admins.Count == 0 ? "(none)" : string.Join(", ", config.Admins))); + break; + case "op": + case "deop": + { + bool makeAdmin = args[0] == "op"; + string target = args.Length > 1 ? args[1].Trim() : ""; + // Direkt eine SteamID64 (17 Ziffern) - geht auch, wenn der Spieler gerade nicht online ist + if (target.Length == 17 && target.All(char.IsDigit)) + { + config.Admins.Remove(target); + if (makeAdmin) config.Admins.Add(target); + config.Save(configPath); + foreach (var online in clients.Values.Where(x => x.SteamId == target)) + { + online.IsAdmin = makeAdmin; + online.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(makeAdmin))); + } + Log($"SteamID {target} is {(makeAdmin ? "now an admin" : "no longer an admin")}"); + break; + } + if (!int.TryParse(target, out int opId) || !clients.TryGetValue(opId, out var who)) + { + Log("Unknown player id (see 'players') - or use a SteamID64"); + break; + } + if (who.SteamId.StartsWith("name:")) + { + Log("Only players with Steam can be admins (their SteamID is verified)"); + break; + } + config.Admins.Remove(who.SteamId); + if (makeAdmin) config.Admins.Add(who.SteamId); + config.Save(configPath); + who.IsAdmin = makeAdmin; + who.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(makeAdmin))); + Log($"{who.Name} is {(makeAdmin ? "now an admin" : "no longer an admin")}"); + break; + } + case "status": + Log($"{config.Name} - {JoinedCount}/{config.MaxPlayers} players, {world.Objects.Count} objects, {world.Events.Count} actions"); + break; + case "players": + foreach (var c in clients.Values.Where(c => c.State == ClientState.Joined)) + Log($" [{c.Id}] {c.Name} SteamID {c.SteamId} {c.Conn.RemoteAddress}"); + break; + case "kick": + case "ban": + { + string[] parts = args.Length > 1 ? args[1].Split(' ', 2) : new string[0]; + if (parts.Length < 1 || !int.TryParse(parts[0], out int id) || !clients.TryGetValue(id, out var target)) + { + Log("Unknown player id (see 'players')"); + break; + } + Log(KickOrBan(target, args[0] == "ban", parts.Length > 1 ? parts[1] : "", "Console")); + break; + } + case "save": + SaveWorld(); + break; + case "reset-world": + world.Reset(); + SaveWorld(); + Log("World state reset - new players get the plain save game again."); + break; + case "reload-save": + LoadSave(); + break; + case "stop": + case "quit": + case "exit": + running = false; + break; + default: + Log("Unknown command, type 'help'"); + break; + } + } + } + + void SaveWorld() + { + try + { + world.Save(worldPath); + Log($"World saved ({world.Objects.Count} objects, {world.Events.Count} actions)"); + } + catch (Exception e) + { + Log("Could not save world: " + e.Message); + } + } + + public static void Log(string text) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {text}"); +} diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj new file mode 100644 index 0000000..3f2f464 --- /dev/null +++ b/MSCO.Server.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + disable + enable + MSCO.Server + MSCO.Server + true + + logo.ico + + + diff --git a/MasterClient.cs b/MasterClient.cs new file mode 100644 index 0000000..44d6f41 --- /dev/null +++ b/MasterClient.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Json; + +namespace MSCO.Server; + +/// +/// 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. +/// Nur Debug-Builds (lokales Testen) dürfen sie mit MSCO_MASTER_URL umstellen. +/// +public static class MasterServer +{ + public const string OfficialUrl = "https://msco.mbm.wtf"; + +#if DEBUG + public static string Url + { + get + { + string custom = Environment.GetEnvironmentVariable("MSCO_MASTER_URL"); + return string.IsNullOrWhiteSpace(custom) ? OfficialUrl : custom.Trim().TrimEnd('/'); + } + } +#else + public static string Url => OfficialUrl; +#endif +} + +/// Spricht mit dem Master-Server: Lizenzschlüssel prüfen und Server in der Liste melden. +public sealed class MasterClient +{ + public sealed record KeyInfo(bool Valid, string Owner, string Label, string Error) + { + /// Master war nicht erreichbar (Netzwerk, Proxy-Fehler) - dann später erneut versuchen. + 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); + + readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) }; + readonly string baseUrl; + + public MasterClient(string masterUrl) + { + baseUrl = masterUrl.TrimEnd('/'); + } + + public async Task 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(); + return info ?? new KeyInfo(false, null, null, "Empty response") { Unreachable = true }; + } + catch (Exception e) + { + return new KeyInfo(false, null, null, e.Message) { Unreachable = true }; + } + } + + /// + /// 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). + /// + public sealed record HeartbeatResult(int Status, bool? Reachable, bool Listed, string Address, int Port); + record HeartbeatReply(bool? Reachable, bool Listed, string Address, int Port); + + /// Beim Beenden: sofort aus der Serverliste nehmen (sonst erst nach dem Heartbeat-Timeout). + 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 HeartbeatAsync(ServerConfig config, int players, string version) + { + try + { + var body = new HeartbeatBody(config.LicenseKey, config.Name, config.Description, config.PublicAddress, config.Port, + players, config.MaxPlayers, version, config.Public, config.RequireSteam); + 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(); } catch { } + return new HeartbeatResult(status, reply?.Reachable, reply?.Listed ?? false, reply?.Address, reply?.Port ?? 0); + } + catch + { + return new HeartbeatResult(0, null, false, null, 0); + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..78f7f55 --- /dev/null +++ b/Program.cs @@ -0,0 +1,172 @@ +using System.Runtime.InteropServices; +using MSCO.Server; + +// Datenordner (server.json, save/, world.json): Standard = Programmordner, +// unter Linux z.B. "--data /var/lib/msco" oder per Umgebungsvariable MSCO_DATA +string dataDir = ArgValue("--data") ?? Environment.GetEnvironmentVariable("MSCO_DATA") ?? AppContext.BaseDirectory; +dataDir = Path.GetFullPath(dataDir); +Directory.CreateDirectory(dataDir); +string configPath = Path.Combine(dataDir, "server.json"); +string commandFile = Path.Combine(dataDir, "commands.in"); + +// --cmd "": Befehl an den laufenden Server geben (z.B. docker exec msco-server msco op 1). +// Der Server liest die Datei jede halbe Sekunde; die Antwort steht im Server-Log (docker logs). +if (Array.IndexOf(args, "--cmd") >= 0) +{ + int at = Array.IndexOf(args, "--cmd"); + string command = string.Join(" ", args.Skip(at + 1)).Trim(); + if (command.Length == 0) { Console.WriteLine("Usage: msco (e.g. msco players, msco op 1, msco say hello)"); return 1; } + File.AppendAllText(commandFile, command + Environment.NewLine); + Console.WriteLine("Sent: " + command + " (output: docker logs --tail 20 msco-server)"); + return 0; +} + +try { Console.Title = "MSCO Server"; } catch { } // nicht jedes Terminal unterstützt einen Titel +GameServer.Log("MSCO dedicated server " + GameServer.ModVersion + " (" + RuntimeInformation.OSDescription + ")"); +GameServer.Log("Data folder: " + dataDir); +GameServer.Log("Master server: " + MasterServer.Url); + +var config = ServerConfig.LoadOrCreate(configPath, out bool created); +var fromEnvironment = config.ApplyEnvironment(); +if (fromEnvironment.Count > 0) + GameServer.Log("Settings overridden by environment (.env): " + string.Join(", ", fromEnvironment)); +if (created && string.IsNullOrWhiteSpace(config.LicenseKey)) +{ + GameServer.Log("Created " + configPath); + GameServer.Log("Get a license key at " + MasterServer.Url + "/keys and put it into server.json (\"LicenseKey\")."); + Pause(); + return 1; +} + +// --import-save [Pfad]: Spielstand aus My Summer Car übernehmen +if (Array.IndexOf(args, "--import-save") >= 0) +{ + string source = ArgValue("--import-save") ?? DefaultSaveFolder(); + if (source == null || !Directory.Exists(source)) + { + GameServer.Log("Save folder not found" + (source != null ? ": " + source : "") + + ". Use --import-save \"/path/to/My Summer Car\" (the folder containing defaultES2File.txt)."); + Pause(); + return 1; + } + string target = Path.Combine(dataDir, "save"); + Directory.CreateDirectory(target); + int copied = 0; + foreach (string name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }) + { + string file = Path.Combine(source, name); + if (!File.Exists(file)) continue; + File.Copy(file, Path.Combine(target, name), true); + copied++; + } + GameServer.Log($"Imported {copied} save files from {source}"); + // Neuer Spielstand = alte Änderungen passen nicht mehr dazu + string world = Path.Combine(dataDir, "world.json"); + if (File.Exists(world)) File.Delete(world); +} + +// Lizenzschlüssel prüfen (wie beim FiveM-Keymaster) +if (string.IsNullOrWhiteSpace(config.LicenseKey)) +{ + GameServer.Log("No license key set. Get one at " + MasterServer.Url + "/keys and put it into server.json."); + Pause(); + return 1; +} +// Automatisch mit dem Master (msco.mbm.wtf) verbinden: ist er gerade nicht erreichbar, +// wird es mit wachsendem Abstand (max. 60 s) weiter versucht, statt aufzugeben. +var master = new MasterClient(MasterServer.Url); +MasterClient.KeyInfo key; +for (int attempt = 1; ; attempt++) +{ + GameServer.Log($"Checking license key at {MasterServer.Url} ..."); + key = await master.ValidateKeyAsync(config.LicenseKey); + if (!key.Unreachable) break; + int wait = Math.Min(60, 5 * attempt); + GameServer.Log($"Master server not reachable ({key.Error}) - retrying in {wait} s (Ctrl+C to cancel)"); + await Task.Delay(TimeSpan.FromSeconds(wait)); +} +if (!key.Valid) +{ + GameServer.Log("License key invalid: " + (key.Error ?? "unknown key")); + GameServer.Log("Manage your keys at " + MasterServer.Url + "/keys"); + Pause(); + return 1; +} +GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})"); + +var server = new GameServer(config, dataDir, configPath, key.Owner); +if (!server.LoadSave()) +{ + Pause(); + return 1; +} + +// Konsolenbefehle in eigenem Thread lesen, ausgeführt werden sie in der Server-Schleife. +// Als Dienst (systemd/Docker ohne Terminal) gibt es keine Eingabe - dann einfach ohne Konsole laufen. +var input = new Thread(() => +{ + try + { + string line; + while ((line = Console.ReadLine()) != null) server.EnqueueCommand(line); + } + catch { } +}) { IsBackground = true }; +input.Start(); + +// Befehle von "msco " (docker exec) aus der Datei commands.in übernehmen +var commandWatcher = new Thread(() => +{ + string processing = commandFile + ".processing"; + while (true) + { + Thread.Sleep(500); + try + { + if (!File.Exists(commandFile)) continue; + File.Move(commandFile, processing, true); + foreach (string line in File.ReadAllLines(processing)) + if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); server.EnqueueCommand(line.Trim()); } + File.Delete(processing); + } + catch { } + } +}) { IsBackground = true }; +commandWatcher.Start(); + +// Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT +Console.CancelKeyPress += (_, e) => { e.Cancel = true; server.EnqueueCommand("stop"); }; +using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; server.EnqueueCommand("stop"); }); +using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; server.EnqueueCommand("stop"); }); + +server.Run(); +return 0; + +string ArgValue(string name) +{ + int i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[i + 1] : null; +} + +// Spielstand dieses Rechners: Windows direkt, Linux über Steam Proton +static string DefaultSaveFolder() +{ + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + const string relative = "AppData/LocalLow/Amistech/My Summer Car"; + if (OperatingSystem.IsWindows()) return Path.Combine(home, relative); + + foreach (string steamRoot in new[] { ".steam/steam", ".local/share/Steam", ".var/app/com.valvesoftware.Steam/.local/share/Steam" }) + { + string proton = Path.Combine(home, steamRoot, "steamapps/compatdata/516750/pfx/drive_c/users/steamuser", relative); + if (Directory.Exists(proton)) return proton; + } + return null; +} + +static void Pause() +{ + // In Docker nicht auf Enter warten: sonst sieht der Container wie "läuft" aus, obwohl der Server beendet ist + if (Console.IsInputRedirected || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true") return; + Console.WriteLine("Press Enter to exit."); + Console.ReadLine(); +} diff --git a/Protocol.cs b/Protocol.cs new file mode 100644 index 0000000..c787696 --- /dev/null +++ b/Protocol.cs @@ -0,0 +1,175 @@ +using System.Net.Sockets; + +namespace MSCO.Server; + +/// Muss mit MsgType in der Mod (NetSession.cs) übereinstimmen. +public enum MsgType : byte +{ + Hello = 1, + Welcome = 2, + Reject = 3, + Player = 4, + SaveBegin = 5, + SaveFile = 6, + StartGame = 7, + ObjStates = 8, + ObjOwn = 9, + ObjRelease = 10, + FsmEvent = 11, + PlayerJoined = 12, + PlayerLeft = 13, + VehicleFx = 14, + Ping = 15, + Ready = 16, // Client -> Server: Spiel geladen, Objekte erfasst + WorldSnapshot = 17,// Server -> Client: aktueller Weltzustand + Info = 18, // Server-Browser fragt Infos ab (ohne Hello) + InfoReply = 19, + WorldTime = 20, // Uhrzeit + Wetter vom "Weltuhr"-Spieler + MoneyDelta = 21, // Geld hat sich geändert (+/- Betrag, gemeinsame Kasse) + DoorState = 22, // Tür/Tor/Haube hat den Zustand gewechselt + AdminStatus = 23, // Server -> Client: bool, ist Admin + AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü + AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin] + AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund + AdminResult = 27, // Server -> Client: string Meldung + Chat = 28, // Chatnachricht (string) + VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert] +} + +public readonly record struct Message(MsgType Type, byte[] Payload) +{ + public BinaryReader Reader() => new(new MemoryStream(Payload)); +} + +public static class Wire +{ + public static byte[] Build(Action write) + { + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + write(w); + w.Flush(); + return ms.ToArray(); + } + + public static byte[] WithSender(int senderId, byte[] payload) + { + var result = new byte[4 + payload.Length]; + BitConverter.GetBytes(senderId).CopyTo(result, 0); + Buffer.BlockCopy(payload, 0, result, 4, payload.Length); + return result; + } + + public static byte[] Frame(MsgType type, byte[] payload) + { + payload ??= Array.Empty(); + var frame = new byte[5 + payload.Length]; + BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0); + frame[4] = (byte)type; + Buffer.BlockCopy(payload, 0, frame, 5, payload.Length); + return frame; + } +} + +/// TCP-Verbindung zu einem Spieler, gleiches Format wie in der Mod: [int Länge][byte Typ][Payload]. +public sealed class Connection +{ + const int MaxFrame = 32 * 1024 * 1024; + + public bool IsClosed { get; private set; } + public string CloseReason { get; private set; } + public string RemoteAddress { get; } + /// Wann zuletzt etwas ankam (Clients schicken alle 2 s einen Ping). + public DateTime LastReceiveUtc { get; private set; } = DateTime.UtcNow; + + readonly TcpClient client; + readonly NetworkStream stream; + readonly byte[] readBuffer = new byte[64 * 1024]; + byte[] buffer = new byte[64 * 1024]; + int count; + + public Connection(TcpClient client) + { + this.client = client; + client.NoDelay = true; + client.SendBufferSize = 4 * 1024 * 1024; + stream = client.GetStream(); + RemoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "?"; + } + + public List Poll() + { + var messages = new List(); + if (IsClosed) return messages; + try + { + if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0) + { + Close("Connection closed"); + return messages; + } + while (stream.DataAvailable) + { + int read = stream.Read(readBuffer, 0, readBuffer.Length); + if (read <= 0) break; + Append(readBuffer, read); + LastReceiveUtc = DateTime.UtcNow; + } + Parse(messages); + } + catch (Exception e) + { + Close("Network error: " + e.Message); + } + return messages; + } + + public void Send(MsgType type, byte[] payload = null) + { + if (IsClosed) return; + try + { + byte[] frame = Wire.Frame(type, payload); + stream.Write(frame, 0, frame.Length); + } + catch (Exception e) + { + Close("Network error: " + e.Message); + } + } + + public void Close(string reason) + { + if (IsClosed) return; + IsClosed = true; + CloseReason = reason; + try { client.Close(); } catch { } + } + + void Append(byte[] data, int length) + { + if (count + length > buffer.Length) Array.Resize(ref buffer, Math.Max(count + length, buffer.Length * 2)); + Buffer.BlockCopy(data, 0, buffer, count, length); + count += length; + } + + void Parse(List messages) + { + int offset = 0; + while (count - offset >= 5) + { + int length = BitConverter.ToInt32(buffer, offset); + if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet"); + if (count - offset - 4 < length) break; + var payload = new byte[length - 1]; + Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length); + messages.Add(new Message((MsgType)buffer[offset + 4], payload)); + offset += 4 + length; + } + if (offset > 0) + { + Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset); + count -= offset; + } + } +} diff --git a/README.md b/README.md index 62435c4..bb38231 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ -# MSCO-Dedicated-Server +# MSCO Dedicated Server +Dedizierter Server für **MSCO – My Summer Car Online**. Er hält einen gemeinsamen Spielstand, speichert alles, +was die Spieler verändern, und gibt es an später Beitretende weiter. .NET 10, läuft unter Linux (Docker oder systemd) +und Windows. + +- Mod: [MSCO](https://git.mbm.wtf/MrMaho/MSCO) +- Keymaster & Serverliste: [MSCO-Keymaster](https://git.mbm.wtf/MrMaho/MSCO-Keymaster) – https://msco.mbm.wtf + +## Voraussetzungen + +- Ein **Lizenzschlüssel** von https://msco.mbm.wtf/keys (Login mit Steam, wie der FiveM-Keymaster). + Der Server prüft ihn beim Start und meldet sich danach regelmäßig beim Master für die Serverliste. +- Ein My-Summer-Car-Spielstand als Startwelt. +- Port **7777/TCP** im Router/Firewall freigegeben (sonst wird der Server nicht gelistet). +- Server und Mod müssen dieselbe Version haben (sonst bekommen Spieler eine Warnung). + +## Mit Docker (empfohlen) + +```bash +git clone https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server.git +cd MSCO-Dedicated-Server +docker compose up -d --build # erster Start legt data/server.json an und beendet sich +``` + +1. In `data/server.json` mindestens `LicenseKey` eintragen. +2. Die Spielstand-Dateien nach `data/save/` kopieren (siehe unten). +3. `docker compose up -d` + +| Aktion | Befehl | +|---|---| +| Konsole | `docker attach msco-server` – verlassen mit **Strg+P, dann Strg+Q** (Strg+C beendet den Server) | +| Einzelner Befehl | `docker exec msco-server msco `, z.B. `msco players` | +| Logs | `docker logs -f msco-server` | +| Stoppen | `docker compose stop` – speichert vorher den Weltzustand und meldet den Server ab | +| Update | `git pull && docker compose up -d --build` | + +Anderer Port: in `data/server.json` **und** in `docker-compose.yml` ändern. + +## Ohne Docker + +**Linux (systemd):** .NET 10 Runtime installieren (`sudo apt install dotnet-runtime-10.0`), dann +`dotnet publish -c Release -o /opt/msco-server` und [`deploy/msco-server.service`](deploy/msco-server.service) +einrichten (Anleitung oben in der Datei). Daten liegen in `/var/lib/msco-server`. +Für eine Konsole den Server stattdessen in `screen`/`tmux` starten. + +**Windows:** `dotnet publish -c Release -o publish`, dann `publish\MSCO.Server.exe` starten. + +Startparameter: +- `--data ` – Datenordner (Standard: Programmordner; Docker: `/data`) +- `--import-save [pfad]` – Spielstand übernehmen (ohne Pfad: der eigene Spielstand dieses PCs, auch Proton) +- `--cmd ` – Befehl an einen laufenden Server im selben Datenordner schicken + +## Spielstand + +In `data/save/` gehören diese Dateien aus dem My-Summer-Car-Spielstand: +`defaultES2File.txt`, `items.txt`, `meshsave.txt`, `graveyard.txt` (und `notepad.txt`, falls vorhanden). + +- Windows: `%USERPROFILE%\AppData\LocalLow\Amistech\My Summer Car` +- Linux (Proton): `~/.steam/steam/steamapps/compatdata/516750/pfx/drive_c/users/steamuser/AppData/LocalLow/Amistech/My Summer Car` + +Neuen Spielstand übernehmen: Dateien ersetzen, `data/world.json` löschen, Server neu starten. + +## Einstellungen (`data/server.json`) + +| Einstellung | Bedeutung | +|---|---| +| `LicenseKey` | Schlüssel von der Keymaster-Seite (Pflicht) | +| `Name`, `Description` | Anzeige im Server-Browser | +| `MaxPlayers`, `Port` | Spieler-Limit (max. 3 sinnvoll), Port | +| `Public` | `false` = nicht in der Liste, nur Direct Connect | +| `RequireSteam` | `true` = nur Spieler mit Steam; `false` = Non-Steam-Server (Spieler werden über den Namen erkannt) | +| `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) | +| `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird | +| `SteamWebApiKey` | optional: Steam-Anmeldetickets der Spieler bei Steam prüfen | +| `BannedSteamIds` | gesperrte Spieler | +| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen | + +Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvariablen (`MSCO_LICENSE_KEY`, +`MSCO_NAME`, `MSCO_PORT`, `MSCO_MAX_PLAYERS`, `MSCO_PUBLIC`, `MSCO_REQUIRE_STEAM`, `MSCO_PUBLIC_ADDRESS`, +`MSCO_STEAM_WEB_API_KEY`) überschreiben `server.json`. + +## Konsolenbefehle + +| Befehl | Beschreibung | +|---|---| +| `status`, `players` | Übersicht, Spielerliste mit IDs | +| `kick [grund]`, `ban [grund]` | Spieler entfernen / sperren | +| `say ` | Chatnachricht an alle | +| `admins`, `op `, `deop <...>` | Admins anzeigen / geben / nehmen | +| `save` | Weltzustand jetzt speichern | +| `reset-world` | alle Änderungen seit dem Spielstand verwerfen | +| `reload-save` | Spielstand-Dateien neu einlesen | +| `stop` | speichern und beenden | + +Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F8-Konsole) ein Kick/Ban-Menü. +Der Chat landet auch im Server-Log. + +## Was der Server speichert + +- `save/` – der Ausgangs-Spielstand, den jeder neue Spieler bekommt. +- `world.json` – alles, was seitdem passiert ist: Positionen bewegter Objekte, Schraub-/Montage-Aktionen, + Geld, Türen/Tore, Fahrzeugzustand (Tank, Öl, Schrauben, Verschleiß …) und die letzte Position jedes Spielers. + +Beim Beitreten: Spielstand laden → Objekte erfassen → Weltzustand anwenden → an der letzten eigenen Position weiterspielen. + +## Sicherheit + +- Die Adresse des Masters (`https://msco.mbm.wtf`) ist fest eingebaut, damit niemand die Schlüsselprüfung mit + einem eigenen Master umgehen kann. Nur Debug-Builds akzeptieren `MSCO_MASTER_URL`. +- `data/` enthält Lizenzschlüssel und API-Keys – nicht veröffentlichen (steht in `.gitignore`). + +## Lizenz + +GNU AGPL-3.0 – siehe [LICENSE](LICENSE). + +Made by **MBM** – [mbm.wtf](https://mbm.wtf) diff --git a/ServerConfig.cs b/ServerConfig.cs new file mode 100644 index 0000000..39c2bac --- /dev/null +++ b/ServerConfig.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace MSCO.Server; + +/// server.json - wird beim ersten Start mit Standardwerten angelegt. +public sealed class ServerConfig +{ + public string LicenseKey { get; set; } = ""; + public string Name { get; set; } = "My MSCO Server"; + public string Description { get; set; } = ""; + public int Port { get; set; } = 7777; + /// + /// Adresse, unter der Spieler den Server erreichen (IP oder Domain). Leer = die IP, von der + /// sich der Server beim Master meldet. Nötig z.B. wenn Master und Server im selben Docker-Netz laufen. + /// + public string PublicAddress { get; set; } = ""; + public int MaxPlayers { get; set; } = 8; + /// In der Serverliste anzeigen. + public bool Public { get; set; } = true; + /// Nur Spieler mit Steam zulassen (false = auch ohne Steam, "Non-Steam"-Server). + public bool RequireSteam { get; set; } = true; + /// Weltzustand so oft auf die Festplatte schreiben. + public int AutosaveSeconds { get; set; } = 60; + /// Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen. + public string SteamWebApiKey { get; set; } = ""; + /// Gesperrte SteamID64s. + public List BannedSteamIds { get; set; } = new(); + /// Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen. + public List Admins { get; set; } = new(); + + static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; + + public static ServerConfig LoadOrCreate(string path, out bool created) + { + created = !File.Exists(path); + if (created) + { + var fresh = new ServerConfig(); + fresh.Save(path); + return fresh; + } + var config = JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? new ServerConfig(); + // Neue Einstellungen (z.B. Admins) auch in ältere server.json schreiben, damit man sie sieht + try { config.Save(path); } catch (Exception) { } // z.B. schreibgeschützt - dann eben nicht + return config; + } + + public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Options)); + + /// + /// Umgebungsvariablen überschreiben server.json (praktisch für Docker/systemd): + /// MSCO_LICENSE_KEY, MSCO_NAME, MSCO_DESCRIPTION, MSCO_PORT, MSCO_MAX_PLAYERS, MSCO_PUBLIC, + /// MSCO_PUBLIC_ADDRESS, MSCO_STEAM_WEB_API_KEY (die Master-Adresse ist fest, siehe MasterServer) + /// + /// Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt). + public List ApplyEnvironment() + { + var used = new List(); + string Take(string name, string current) + { + string value = Env(name); + if (value == null) return current; + used.Add(name); + return value; + } + + LicenseKey = Take("MSCO_LICENSE_KEY", LicenseKey); + Name = Take("MSCO_NAME", Name); + Description = Take("MSCO_DESCRIPTION", Description); + PublicAddress = Take("MSCO_PUBLIC_ADDRESS", PublicAddress); + SteamWebApiKey = Take("MSCO_STEAM_WEB_API_KEY", SteamWebApiKey); + if (int.TryParse(Env("MSCO_PORT"), out int port)) { Port = port; used.Add("MSCO_PORT"); } + if (int.TryParse(Env("MSCO_MAX_PLAYERS"), out int max)) { MaxPlayers = max; used.Add("MSCO_MAX_PLAYERS"); } + if (bool.TryParse(Env("MSCO_PUBLIC"), out bool isPublic)) { Public = isPublic; used.Add("MSCO_PUBLIC"); } + if (bool.TryParse(Env("MSCO_REQUIRE_STEAM"), out bool requireSteam)) { RequireSteam = requireSteam; used.Add("MSCO_REQUIRE_STEAM"); } + return used; + } + + static string Env(string name) + { + string value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/SteamVerifier.cs b/SteamVerifier.cs new file mode 100644 index 0000000..7bf5b31 --- /dev/null +++ b/SteamVerifier.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace MSCO.Server; + +/// +/// Prüft Steam-Anmeldetickets der Spieler über die Steam Web API +/// (ISteamUserAuth/AuthenticateUserTicket), sofern in server.json ein Key eingetragen ist. +/// Ohne Key wird nur geprüft, dass der Client eine Steam-ID und ein Ticket mitschickt. +/// +public sealed class SteamVerifier +{ + const int MySummerCarAppId = 516750; + + readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) }; + readonly string apiKey; + + public bool Enabled => !string.IsNullOrWhiteSpace(apiKey); + + public SteamVerifier(string apiKey) + { + this.apiKey = apiKey; + } + + /// Gibt null zurück, wenn alles passt, sonst den Ablehnungsgrund. + public async Task VerifyAsync(ulong claimedSteamId, byte[] ticket, bool requireSteam) + { + bool hasSteam = claimedSteamId != 0 && ticket != null && ticket.Length > 0; + if (!hasSteam) return requireSteam ? "Steam is required to join this server" : null; // Non-Steam-Server + if (!Enabled) return null; + + string url = "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" + apiKey + + "&appid=" + MySummerCarAppId + "&ticket=" + Convert.ToHexString(ticket); + try + { + // Frisch erzeugte Tickets sind manchmal erst nach einem Moment gültig + for (int attempt = 0; attempt < 3; attempt++) + { + using var doc = JsonDocument.Parse(await http.GetStringAsync(url)); + var response = doc.RootElement.GetProperty("response"); + if (response.TryGetProperty("params", out var p)) + { + string steamId = p.GetProperty("steamid").GetString(); + if (steamId != claimedSteamId.ToString()) return "Steam ticket does not match your account"; + if (p.TryGetProperty("vacbanned", out var vac) && vac.GetBoolean()) return "VAC banned"; + return null; + } + await Task.Delay(1000); + } + return "Steam could not verify your ticket"; + } + catch (Exception e) + { + Console.WriteLine("[steam] verification failed: " + e.Message); + return "Steam verification failed, try again"; + } + } +} diff --git a/WorldState.cs b/WorldState.cs new file mode 100644 index 0000000..8d6cdc8 --- /dev/null +++ b/WorldState.cs @@ -0,0 +1,185 @@ +using System.Text.Json; + +namespace MSCO.Server; + +/// +/// Was sich seit dem Ausgangs-Spielstand (Ordner "save") geändert hat: +/// Positionen aller bewegten Objekte, alle Schraub-/Montage-Aktionen (in Reihenfolge) +/// und wo jeder Spieler zuletzt war. Neue Spieler laden den Spielstand und bekommen +/// danach diesen Zustand als Snapshot. +/// +public sealed class WorldState +{ + public sealed class ObjectPose + { + public float[] P { get; set; } = new float[3]; + public float[] R { get; set; } = new float[4]; + } + + public Dictionary Objects { get; set; } = new(); + /// FsmEvent-Payloads (Base64) in der Reihenfolge, in der sie passiert sind. + public List Events { get; set; } = new(); + /// SteamID64 -> letzte Position. + public Dictionary PlayerPositions { get; set; } = new(); + /// Summe aller Geldänderungen seit dem Ausgangs-Spielstand (gemeinsame Kasse). + public float MoneyDelta { get; set; } + /// Letzter Zustand jeder Tür/jedes Tors (FSM-Schlüssel -> Zustand). + public Dictionary Doors { get; set; } = new(); + /// Fahrzeugzustand (Tank, Öl, Schrauben ...): Schlüssel -> Base64 [byte typ, wert]. + public Dictionary Vars { get; set; } = new(); + + public bool Dirty { get; private set; } + + static readonly JsonSerializerOptions Options = new() { WriteIndented = false }; + + public static WorldState Load(string path) + { + if (!File.Exists(path)) return new WorldState(); + return JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? new WorldState(); + } + + public void Save(string path) + { + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(this, Options)); + File.Move(tmp, path, true); + Dirty = false; + } + + public void Reset() + { + Objects.Clear(); + Events.Clear(); + PlayerPositions.Clear(); + MoneyDelta = 0f; + Doors.Clear(); + Vars.Clear(); + Dirty = true; + } + + // ---------------------------------------------------------------- Aus Spiel-Nachrichten lernen + + /// ObjStates: int Anzahl, dann je [int id, 3 float pos, 4 float rot, 3 float vel]. + public void ApplyObjStates(byte[] payload) + { + using var r = new BinaryReader(new MemoryStream(payload)); + int count = r.ReadInt32(); + for (int i = 0; i < count; i++) ReadPose(r); + Dirty = true; + } + + /// ObjRelease: ein Eintrag wie bei ObjStates (Endposition). + public void ApplyObjRelease(byte[] payload) + { + using var r = new BinaryReader(new MemoryStream(payload)); + ReadPose(r); + Dirty = true; + } + + void ReadPose(BinaryReader r) + { + int id = r.ReadInt32(); + if (!Objects.TryGetValue(id, out var pose)) Objects[id] = pose = new ObjectPose(); + for (int k = 0; k < 3; k++) pose.P[k] = r.ReadSingle(); + for (int k = 0; k < 4; k++) pose.R[k] = r.ReadSingle(); + r.ReadSingle(); r.ReadSingle(); r.ReadSingle(); // Geschwindigkeit wird nicht gespeichert + } + + public void ApplyMoney(byte[] payload) + { + if (payload.Length < 4) return; + MoneyDelta += BitConverter.ToSingle(payload, 0); + Dirty = true; + } + + public void ApplyDoor(byte[] payload) + { + using var r = new BinaryReader(new MemoryStream(payload)); + string key = r.ReadString(); + Doors[key] = r.ReadString(); + Dirty = true; + } + + public void ApplyVars(byte[] payload) + { + using var r = new BinaryReader(new MemoryStream(payload)); + int count = r.ReadInt32(); + if (count < 0 || count > 10000) return; + for (int i = 0; i < count; i++) + { + int key = r.ReadInt32(); + byte type = r.ReadByte(); + byte[] value = r.ReadBytes(type == 3 ? 1 : 4); + var all = new byte[value.Length + 1]; + all[0] = type; + value.CopyTo(all, 1); + Vars[key] = Convert.ToBase64String(all); + } + Dirty = true; + } + + public void AddFsmEvent(byte[] payload) + { + Events.Add(Convert.ToBase64String(payload)); + Dirty = true; + } + + /// Player: beginnt mit der Position (3 float). + public void ApplyPlayer(string steamId, byte[] payload) + { + if (string.IsNullOrEmpty(steamId) || payload.Length < 12) return; + PlayerPositions[steamId] = new[] + { + BitConverter.ToSingle(payload, 0), BitConverter.ToSingle(payload, 4), BitConverter.ToSingle(payload, 8) + }; + Dirty = true; + } + + // ---------------------------------------------------------------- Snapshot für neue Spieler + + /// + /// WorldSnapshot: bool hatSpawn [3 float], int Objekte, je [int id, 3 float pos, 4 float rot], + /// int Events, je [int Länge, bytes FsmEvent-Payload], float Geldänderung seit dem Spielstand. + /// + public byte[] BuildSnapshot(string steamId) + { + return Wire.Build(w => + { + bool hasSpawn = steamId != null && PlayerPositions.TryGetValue(steamId, out _); + w.Write(hasSpawn); + if (hasSpawn) + foreach (float f in PlayerPositions[steamId]) w.Write(f); + + w.Write(Objects.Count); + foreach (var kv in Objects) + { + w.Write(kv.Key); + foreach (float f in kv.Value.P) w.Write(f); + foreach (float f in kv.Value.R) w.Write(f); + } + + w.Write(Events.Count); + foreach (string e in Events) + { + byte[] bytes = Convert.FromBase64String(e); + w.Write(bytes.Length); + w.Write(bytes); + } + + // Geld: Summe der Änderungen seit dem Spielstand + w.Write(MoneyDelta); + + // Türen/Tore: letzter Zustand + w.Write(Doors.Count); + foreach (var kv in Doors) { w.Write(kv.Key); w.Write(kv.Value); } + + // Fahrzeugzustand: int n, je [int key, byte typ, wert] + w.Write(Vars.Count); + foreach (var kv in Vars) + { + w.Write(kv.Key); + w.Write(Convert.FromBase64String(kv.Value)); + } + }); + } +} diff --git a/deploy/msco-server.service b/deploy/msco-server.service new file mode 100644 index 0000000..225cb0b --- /dev/null +++ b/deploy/msco-server.service @@ -0,0 +1,27 @@ +# MSCO dedizierter Server als systemd-Dienst +# Installation: +# sudo useradd --system --home /var/lib/msco-server --create-home msco +# dotnet publish -c Release -o /opt/msco-server +# sudo cp deploy/msco-server.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl enable --now msco-server +# Logs: journalctl -u msco-server -f + +[Unit] +Description=MSCO dedicated server (My Summer Car Online) +After=network-online.target +Wants=network-online.target + +[Service] +User=msco +WorkingDirectory=/opt/msco-server +ExecStart=/usr/bin/dotnet /opt/msco-server/MSCO.Server.dll --data /var/lib/msco-server +# Beim Stoppen speichert der Server den Weltzustand (SIGTERM) +KillSignal=SIGTERM +TimeoutStopSec=30 +Restart=on-failure +RestartSec=10 +# Exit-Code 1 = Konfigurationsfehler (kein/ungültiger Schlüssel, kein Spielstand): nicht endlos neu starten +RestartPreventExitStatus=1 + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e288473 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +# MSCO dedizierter Gameserver mit Docker. +# +# docker compose up -d --build # erster Start legt data/server.json an und beendet sich +# -> in data/server.json den LicenseKey (von https://msco.mbm.wtf/keys), Name, MaxPlayers ... eintragen +# -> Spielstand-Dateien von My Summer Car nach data/save/ kopieren +# docker compose up -d +# +# Konsole: docker attach msco-server (verlassen mit Strg+P, dann Strg+Q - NICHT Strg+C) +# Befehl: docker exec msco-server msco (z.B. msco players, msco op ) +# Logs: docker logs -f msco-server +# Stoppen: docker compose stop (speichert den Weltzustand) + +services: + server: + build: . + container_name: msco-server + restart: "on-failure:3" # bei fehlendem Schlüssel/Spielstand nicht endlos neu starten + stdin_open: true # Server-Konsole per docker attach + tty: true + stop_grace_period: 30s + ports: + - "7777:7777/tcp" + volumes: + - ./data:/data diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000..7d5f9b3 Binary files /dev/null and b/logo.ico differ