From 0d289879f4a07d808edffc0b185aa6e58db696ac Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sat, 26 Sep 2026 19:45:02 +0200 Subject: [PATCH] MSCO dedicated server 2.6: code, Docker, systemd, README Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 5 + .gitignore | 6 + Dockerfile | 21 ++ GameServer.cs | 524 +++++++++++++++++++++++++++++++++++++ MSCO.Server.csproj | 15 ++ MasterClient.cs | 101 +++++++ Program.cs | 172 ++++++++++++ Protocol.cs | 175 +++++++++++++ README.md | 117 ++++++++- ServerConfig.cs | 84 ++++++ SteamVerifier.cs | 57 ++++ WorldState.cs | 185 +++++++++++++ deploy/msco-server.service | 27 ++ docker-compose.yml | 24 ++ logo.ico | Bin 0 -> 23784 bytes 15 files changed, 1512 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 GameServer.cs create mode 100644 MSCO.Server.csproj create mode 100644 MasterClient.cs create mode 100644 Program.cs create mode 100644 Protocol.cs create mode 100644 ServerConfig.cs create mode 100644 SteamVerifier.cs create mode 100644 WorldState.cs create mode 100644 deploy/msco-server.service create mode 100644 docker-compose.yml create mode 100644 logo.ico 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 0000000000000000000000000000000000000000..7d5f9b3aeddad6aaa71c703a45d8a27d7b87d859 GIT binary patch literal 23784 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∈v-b8w0FRkt#F= z(k!D1HSj z{i+XEUmF^|j_dZsvnkQ@QNw5kUZvNFkTrx}0d;@L*4XP36cdquW@p!{^5#B=WRI3g z|1yEM%FaAA`s&Ewdylx-5iD)C$5g=IKO;4NmAhdO!hcnh_Tt&{zp9BAg@l9_h3sUPcZY<8;{T6oipIt1s)IWSkoZ}Wr462Hi+i?r^S;xJK zrM8e2+$3en>!kTpHEYa)T}qqTDgL%>vW?D$L>|g&CU))DRGditNoGqAw;HyQDLK-po&|tT>1FeMtf(43q|3E8Pn|2gBDgZX)F>xp_iowyl>SA&l~Kn8tepM8Ua&Z z5AoH8mO@DItVQ1#qkVwY{#jxh(}2^ynyCOVNv!w+yf@w!V0av9w7s2l3zg<%Ua0Jl zM8z9i_iI9-Z$?f`<=q+H7!)H4xR3{LvAPl P)&=I)ycVVZw~zk=0830> literal 0 HcmV?d00001