#region Copyright & License Information /* * Copyright (c) The OpenRA Developers and Contributors * This file is part of OpenRA, which is free software. It is made * available to you under the terms of the GNU General Public License * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. For more * information, see COPYING. */ #endregion using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using OpenRA; using OpenRA.FileFormats; using OpenRA.Network; using OpenRA.Primitives; using OpenRA.Support; using OpenRA.Traits; namespace OpenRA.Server { public enum ServerState { WaitingPlayers = 1, GameStarted = 2, ShuttingDown = 3 } public enum ServerType { Local = 0, Skirmish = 1, Multiplayer = 2, Dedicated = 3 } public sealed class Server { [FluentReference] const string CustomRules = "notification-custom-rules"; [FluentReference] const string BotsDisabled = "notification-map-bots-disabled"; [FluentReference] const string TwoHumansRequired = "notification-two-humans-required"; [FluentReference] const string ErrorGameStarted = "notification-error-game-started"; [FluentReference] const string RequiresPassword = "notification-requires-password"; [FluentReference] const string IncorrectPassword = "notification-incorrect-password"; [FluentReference] const string IncompatibleMod = "notification-incompatible-mod"; [FluentReference] const string IncompatibleVersion = "notification-incompatible-version"; [FluentReference] const string IncompatibleProtocol = "notification-incompatible-protocol"; [FluentReference] const string Banned = "notification-you-were-banned"; [FluentReference] const string TempBanned = "notification-you-were-temp-banned"; [FluentReference] const string Full = "notification-game-full"; [FluentReference("player")] const string Joined = "notification-joined"; [FluentReference] const string RequiresAuthentication = "notification-requires-authentication"; [FluentReference] const string NoPermission = "notification-no-permission-to-join"; [FluentReference("command")] const string UnknownServerCommand = "notification-unknown-server-command"; [FluentReference("player")] const string LobbyDisconnected = "notification-lobby-disconnected"; [FluentReference("player")] const string PlayerDisconnected = "notification-player-disconnected"; [FluentReference("player", "team")] const string PlayerTeamDisconnected = "notification-team-player-disconnected"; [FluentReference("player")] const string ObserverDisconnected = "notification-observer-disconnected"; [FluentReference("player")] const string NewAdmin = "notification-new-admin"; [FluentReference] const string YouWereKicked = "notification-you-were-kicked"; [FluentReference] const string GameStarted = "notification-game-started"; public readonly MersenneTwister Random = new(); public readonly ServerType Type; public bool IsMultiplayer => Type == ServerType.Dedicated || Type == ServerType.Multiplayer; public readonly List Conns = new(); public Session LobbyInfo; public ServerSettings Settings; public ModData ModData; public List TempBans = new(); // Managed by LobbyCommands public MapPreview Map; public readonly MapStatusCache MapStatusCache; public GameSave GameSave; public HashSet MapPool; // Default to the next frame for ServerType.Local - MP servers take the value from the selected GameSpeed. public int OrderLatency = 1; readonly int randomSeed; readonly List listeners = new(); readonly TypeDictionary serverTraits = new(); readonly PlayerDatabase playerDatabase; OrderBuffer orderBuffer; volatile ServerState internalState = ServerState.WaitingPlayers; readonly BlockingCollection events = new(); ReplayRecorder recorder; GameInformation gameInfo; readonly List worldPlayers = new(); readonly Stopwatch pingUpdated = Stopwatch.StartNew(); public readonly VoteKickTracker VoteKickTracker; readonly PlayerMessageTracker playerMessageTracker; public ServerState State { get => internalState; set => internalState = value; } public static void SyncClientToPlayerReference(Session.Client c, PlayerReference pr) { if (pr == null) return; if (pr.LockFaction) c.Faction = pr.Faction; if (pr.LockSpawn) c.SpawnPoint = pr.Spawn; if (pr.LockTeam) c.Team = pr.Team; if (pr.LockHandicap) c.Handicap = pr.Handicap; c.Color = pr.LockColor ? pr.Color : c.PreferredColor; } public void Shutdown() { State = ServerState.ShuttingDown; } public void EndGame() { foreach (var t in serverTraits.WithInterface()) t.GameEnded(this); recorder?.Dispose(); recorder = null; } // Craft a fake handshake request/response because that's the // only way to expose the Version and OrdersProtocol. public void RecordFakeHandshake() { var request = new HandshakeRequest { Mod = ModData.Manifest.Id, Version = ModData.Manifest.Metadata.Version, }; recorder.ReceiveFrame(0, 0, new Order("HandshakeRequest", null, false) { Type = OrderType.Handshake, IsImmediate = true, TargetString = request.Serialize(), }.Serialize()); var response = new HandshakeResponse() { Mod = ModData.Manifest.Id, Version = ModData.Manifest.Metadata.Version, OrdersProtocol = ProtocolVersion.Orders, Client = new Session.Client(), }; recorder.ReceiveFrame(0, 0, new Order("HandshakeResponse", null, false) { Type = OrderType.Handshake, IsImmediate = true, TargetString = response.Serialize(), }.Serialize()); } void MapStatusChanged(string uid, Session.MapStatus status) { lock (LobbyInfo) { if (LobbyInfo.GlobalSettings.Map == uid) LobbyInfo.GlobalSettings.MapStatus = status; SyncLobbyInfo(); } } public Server(List endpoints, ServerSettings settings, ModData modData, ServerType type) { Log.AddChannel("server", "server.log", true); SocketException lastException = null; foreach (var endpoint in endpoints) { var listener = new TcpListener(endpoint); try { try { listener.Server.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, 1); } catch (Exception ex) when (ex is SocketException || ex is ArgumentException) { Log.Write("server", $"Failed to set socket option on {endpoint}: {ex.Message}"); } listener.Start(); listeners.Add(listener); new Thread(() => { while (true) { if (State != ServerState.WaitingPlayers) { listener.Stop(); return; } // Use a 1s timeout so we can stop listening once the game starts if (listener.Server.Poll(1000000, SelectMode.SelectRead)) { try { events.Add(new ConnectionConnectEvent(listener.AcceptSocket())); } catch (Exception) { // Ignore the exception that may be generated if the connection // drops while we are trying to connect } } } }) { Name = $"Connection listener ({listener.LocalEndpoint})", IsBackground = true }.Start(); } catch (SocketException ex) { lastException = ex; Log.Write("server", $"Failed to listen on {endpoint}: {ex.Message}"); } } if (listeners.Count == 0) throw lastException; Type = type; Settings = settings; Settings.Name = Game.Settings.SanitizedServerName(Settings.Name); ModData = modData; playerDatabase = modData.Manifest.Get(); randomSeed = (int)DateTime.Now.ToBinary(); if (IsMultiplayer && settings.EnableGeoIP) GeoIP.Initialize(); if (IsMultiplayer) Nat.TryForwardPort(Settings.ListenPort, Settings.ListenPort); foreach (var trait in modData.Manifest.ServerTraits) serverTraits.Add(modData.ObjectCreator.CreateObject(trait)); serverTraits.TrimExcess(); MapStatusCache = new MapStatusCache(modData, MapStatusChanged, type == ServerType.Dedicated && settings.EnableLintChecks); playerMessageTracker = new PlayerMessageTracker(this, DispatchOrdersToClient, SendFluentMessageTo); VoteKickTracker = new VoteKickTracker(this); LobbyInfo = new Session { GlobalSettings = { RandomSeed = randomSeed, ServerName = settings.Name, EnableSingleplayer = settings.EnableSingleplayer || Type != ServerType.Dedicated, EnableSyncReports = settings.EnableSyncReports, GameUid = Guid.NewGuid().ToString(), Dedicated = Type == ServerType.Dedicated } }; if (Settings.RecordReplays && Type == ServerType.Dedicated) { recorder = new ReplayRecorder(() => Game.TimestampedFilename(extra: "-Server")); // We only need one handshake to initialize the replay. // Add it now, then ignore the redundant handshakes from each client RecordFakeHandshake(); } new Thread(_ => { // Note: at least one of these is required to set the initial LobbyInfo.Map and MapStatus foreach (var t in serverTraits.WithInterface()) t.ServerStarted(this); Log.Write("server", $"Initial mod: {ModData.Manifest.Id}"); Log.Write("server", $"Initial map: {LobbyInfo.GlobalSettings.Map}"); while (true) { if (State != ServerState.ShuttingDown) { if (events.TryTake(out var e, 1000)) e.Invoke(this); // PERF: Dedicated servers need to drain the action queue to remove references blocking the GC from cleaning up disposed objects. if (Type == ServerType.Dedicated) Game.PerformDelayedActions(); foreach (var t in serverTraits.WithInterface()) t.Tick(this); if (State == ServerState.GameStarted) { foreach (var (playerIndex, scale) in orderBuffer.GetTickScales()) { var frame = CreateTickScaleFrame(scale); var con = Conns.SingleOrDefault(c => c.PlayerIndex == playerIndex); if (con != null && con.Validated) DispatchFrameToClient(con, playerIndex, frame); } } } if (State == ServerState.ShuttingDown) { EndGame(); if (IsMultiplayer) Nat.TryRemovePortForward(); break; } } foreach (var t in serverTraits.WithInterface()) t.ServerShutdown(this); // Make sure to immediately close connections after the server is shutdown, we don't want to keep clients waiting foreach (var c in Conns) c.Dispose(); Conns.Clear(); }) { IsBackground = true }.Start(); } int nextPlayerIndex; public int ChooseFreePlayerIndex() { return nextPlayerIndex++; } internal void OnConnectionPacket(Connection conn, int frame, byte[] data) { events.Add(new ConnectionPacketEvent(conn, frame, data)); } internal void OnConnectionPing(Connection conn, int[] pingHistory, byte queueLength) { events.Add(new ConnectionPingEvent(conn, pingHistory, queueLength)); } internal void OnConnectionDisconnect(Connection conn) { events.Add(new ConnectionDisconnectEvent(conn)); } void AcceptConnection(Socket socket) { if (State != ServerState.WaitingPlayers) return; // Validate player identity by asking them to sign a random blob of data // which we can then verify against the player public key database var token = Convert.ToBase64String(OpenRA.Exts.MakeArray(256, _ => (byte)Random.Next())); var newConn = new Connection(this, socket, token); try { // Send handshake and client index. var ms = new MemoryStream(8); ms.Write(ProtocolVersion.Handshake); ms.Write(newConn.PlayerIndex); newConn.TrySendData(ms.ToArray()); // Dispatch a handshake order var request = new HandshakeRequest { Mod = ModData.Manifest.Id, Version = ModData.Manifest.Metadata.Version, AuthToken = token }; DispatchOrdersToClient(newConn, 0, 0, new Order("HandshakeRequest", null, false) { Type = OrderType.Handshake, IsImmediate = true, TargetString = request.Serialize() }.Serialize()); } catch (Exception e) { Log.Write("server", $"Handshake for client {newConn.EndPoint} failed: {e}"); } Conns.Add(newConn); } void ValidateClient(Connection newConn, string data, string name) { try { if (State == ServerState.GameStarted) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; game is already started."); SendOrderTo(newConn, "ServerError", ErrorGameStarted); DropClient(newConn); return; } var handshake = HandshakeResponse.Deserialize(data, name); if (!string.IsNullOrEmpty(Settings.Password) && handshake.Password != Settings.Password) { var message = string.IsNullOrEmpty(handshake.Password) ? RequiresPassword : IncorrectPassword; SendOrderTo(newConn, "AuthenticationError", message); DropClient(newConn); return; } var ipAddress = ((IPEndPoint)newConn.EndPoint).Address; var client = new Session.Client { Name = OpenRA.Settings.SanitizedPlayerName(handshake.Client.Name), IPAddress = ipAddress.ToString(), AnonymizedIPAddress = IsMultiplayer && Settings.ShareAnonymizedIPs ? Session.AnonymizeIP(ipAddress) : null, Location = GeoIP.LookupCountry(ipAddress), Index = newConn.PlayerIndex, PreferredColor = handshake.Client.PreferredColor, Color = handshake.Client.Color, Faction = "Random", SpawnPoint = 0, Team = 0, Handicap = 0, State = Session.ClientState.Invalid, }; if (ModData.Manifest.Id != handshake.Mod) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; mods do not match."); SendOrderTo(newConn, "ServerError", IncompatibleMod); DropClient(newConn); return; } if (ModData.Manifest.Metadata.Version != handshake.Version) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not running the same version."); SendOrderTo(newConn, "ServerError", IncompatibleVersion); DropClient(newConn); return; } if (handshake.OrdersProtocol != ProtocolVersion.Orders) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; incompatible Orders protocol version {handshake.OrdersProtocol}."); SendOrderTo(newConn, "ServerError", IncompatibleProtocol); DropClient(newConn); return; } // Check if IP is banned var bans = Settings.Ban.Union(TempBans); if (bans.Contains(client.IPAddress)) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Banned."); var message = Settings.Ban.Contains(client.IPAddress) ? Banned : TempBanned; SendOrderTo(newConn, "ServerError", message); DropClient(newConn); return; } void CompleteConnection() { lock (LobbyInfo) { client.Slot = LobbyInfo.FirstEmptySlot(); client.IsAdmin = !LobbyInfo.Clients.Any(c => c.IsAdmin); if (client.IsObserver && !LobbyInfo.GlobalSettings.AllowSpectators) { SendOrderTo(newConn, "ServerError", Full); DropClient(newConn); return; } if (client.Slot != null) SyncClientToPlayerReference(client, Map.Players.Players[client.Slot]); else client.Color = Color.White; // Promote connection to a valid client LobbyInfo.Clients.Add(client); newConn.Validated = true; // Disable chat UI to stop the client sending messages that we know we will reject if (!client.IsAdmin && Settings.FloodLimitJoinCooldown > 0) playerMessageTracker.DisableChatUI(newConn, Settings.FloodLimitJoinCooldown); Log.Write("server", $"Client {newConn.PlayerIndex}: Accepted connection from {newConn.EndPoint}."); if (client.Fingerprint != null) Log.Write("server", $"Client {newConn.PlayerIndex}: Player fingerprint is {client.Fingerprint}."); foreach (var t in serverTraits.WithInterface()) t.ClientJoined(this, newConn); SyncLobbyInfo(); Log.Write("server", $"{client.Name} ({newConn.EndPoint}) has joined the game."); SendFluentMessage(Joined, "player", client.Name); if (Type == ServerType.Dedicated) { var motdFile = Path.Combine(Platform.SupportDir, "motd.txt"); if (!File.Exists(motdFile)) File.WriteAllText(motdFile, "Welcome, have fun and good luck!"); var motd = File.ReadAllText(motdFile); if (!string.IsNullOrEmpty(motd)) SendOrderTo(newConn, "Message", motd); } if ((LobbyInfo.GlobalSettings.MapStatus & Session.MapStatus.UnsafeCustomRules) != 0) SendFluentMessageTo(newConn, CustomRules); if (!LobbyInfo.GlobalSettings.EnableSingleplayer) SendFluentMessageTo(newConn, TwoHumansRequired); else if (Map.Players.Players.Where(p => p.Value.Playable).All(p => !p.Value.AllowBots)) SendFluentMessageTo(newConn, BotsDisabled); } } if (!IsMultiplayer) { // Local servers can only be joined by the local client, so we can trust their identity without validation client.Fingerprint = handshake.Fingerprint; CompleteConnection(); } else if (!string.IsNullOrEmpty(handshake.Fingerprint) && !string.IsNullOrEmpty(handshake.AuthSignature)) { Task.Run(async () => { PlayerProfile profile = null; try { var httpClient = HttpClientFactory.Create(); var url = playerDatabase.Profile + handshake.Fingerprint; var httpResponseMessage = await httpClient.GetAsync(url); var result = await httpResponseMessage.Content.ReadAsStreamAsync(); var yaml = MiniYaml.FromStream(result, url).First(); if (yaml.Key == "Player") { profile = FieldLoader.Load(yaml.Value); var publicKey = Encoding.ASCII.GetString(Convert.FromBase64String(profile.PublicKey)); var parameters = CryptoUtil.DecodePEMPublicKey(publicKey); if (!profile.KeyRevoked && CryptoUtil.VerifySignature(parameters, newConn.AuthToken, handshake.AuthSignature)) { client.Fingerprint = handshake.Fingerprint; Log.Write("server", $"{newConn.EndPoint} authenticated as {profile.ProfileName} (UID {profile.ProfileID})"); } else if (profile.KeyRevoked) { profile = null; Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (key revoked)"); } else { profile = null; Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (signature verification failed)"); } } else Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (invalid server response: `{yaml.Key}` is not `Player`)"); } catch (Exception ex) { Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (exception occurred)"); Log.Write("server", ex.ToString()); } events.Add(new CallbackEvent(() => { var notAuthenticated = Type == ServerType.Dedicated && profile == null && (Settings.RequireAuthentication || Settings.ProfileIDWhitelist.Length > 0); var blacklisted = Type == ServerType.Dedicated && profile != null && Settings.ProfileIDBlacklist.Contains(profile.ProfileID); var notWhitelisted = Type == ServerType.Dedicated && Settings.ProfileIDWhitelist.Length > 0 && (profile == null || !Settings.ProfileIDWhitelist.Contains(profile.ProfileID)); if (notAuthenticated) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not authenticated."); SendOrderTo(newConn, "ServerError", RequiresAuthentication); DropClient(newConn); } else if (blacklisted || notWhitelisted) { if (blacklisted) Log.Write("server", $"Rejected connection from {newConn.EndPoint}; In server blacklist."); else Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not in server whitelist."); SendOrderTo(newConn, "ServerError", NoPermission); DropClient(newConn); } else CompleteConnection(); })); }); } else { if (Type == ServerType.Dedicated && (Settings.RequireAuthentication || Settings.ProfileIDWhitelist.Length > 0)) { Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not authenticated."); SendOrderTo(newConn, "ServerError", RequiresAuthentication); DropClient(newConn); } else CompleteConnection(); } } catch (Exception ex) { Log.Write("server", $"Dropping connection {newConn.EndPoint} because an error occurred:"); Log.Write("server", ex.ToString()); DropClient(newConn); } } static byte[] CreateFrame(int client, int frame, byte[] data) { var ms = new MemoryStream(data.Length + 12); ms.Write(data.Length + 4); ms.Write(client); ms.Write(frame); ms.Write(data); return ms.GetBuffer(); } static byte[] CreateAckFrame(int frame, byte count) { var ms = new MemoryStream(14); ms.Write(6); ms.Write(0); ms.Write(frame); ms.WriteByte((byte)OrderType.Ack); ms.WriteByte(count); return ms.GetBuffer(); } static byte[] CreateTickScaleFrame(float scale) { var ms = new MemoryStream(17); ms.Write(9); ms.Write(0); ms.Write(0); ms.WriteByte((byte)OrderType.TickScale); ms.Write(scale); return ms.GetBuffer(); } void DispatchOrdersToClient(Connection c, int client, int frame, byte[] data) { DispatchFrameToClient(c, client, CreateFrame(client, frame, data)); } void DispatchFrameToClient(Connection c, int client, byte[] frameData) { if (!c.TrySendData(frameData)) { DropClient(c); Log.Write("server", $"Dropping client {client.ToString(CultureInfo.InvariantCulture)} because dispatching orders failed!"); } } bool AnyUndefinedWinStates() { var lastTeam = -1; var remainingPlayers = gameInfo.Players.Where(p => p.Outcome == WinState.Undefined); foreach (var player in remainingPlayers) { if (lastTeam >= 0 && (player.Team != lastTeam || player.Team == 0)) return true; lastTeam = player.Team; } return false; } void SetPlayerDefeat(int playerIndex) { var defeatedPlayer = worldPlayers[playerIndex]; if (defeatedPlayer == null || defeatedPlayer.Outcome != WinState.Undefined) return; defeatedPlayer.Outcome = WinState.Lost; defeatedPlayer.OutcomeTimestampUtc = DateTime.UtcNow; // Set remaining players as winners if only one side remains if (!AnyUndefinedWinStates()) { var now = DateTime.UtcNow; var remainingPlayers = gameInfo.Players.Where(p => p.Outcome == WinState.Undefined); foreach (var winner in remainingPlayers) { winner.Outcome = WinState.Won; winner.OutcomeTimestampUtc = now; } } } void OutOfSync(int frame) { Log.Write("server", $"Out of sync detected at frame {frame}, cancel replay recording"); // Make sure the written file is not valid // TODO: storing a serverside replay on desync would be extremely useful if (recorder != null) { recorder.Metadata = null; recorder.Dispose(); } // Stop the recording recorder = null; } readonly Dictionary syncForFrame = new(); int lastDefeatStateFrame; ulong lastDefeatState; void HandleSyncOrder(int frame, byte[] packet) { if (syncForFrame.TryGetValue(frame, out var existingSync)) { if (packet.Length != existingSync.Length) { OutOfSync(frame); return; } for (var i = 0; i < packet.Length; i++) { if (packet[i] != existingSync[i]) { OutOfSync(frame); return; } } } else { // Update player losses based on the new defeat state. // Do this once for the first player, the check above // guarantees a desync if any other player disagrees. var playerDefeatState = BitConverter.ToUInt64(packet, 1 + 4); if (frame > lastDefeatStateFrame && lastDefeatState != playerDefeatState) { var newDefeats = playerDefeatState & ~lastDefeatState; for (var i = 0; i < worldPlayers.Count; i++) if ((newDefeats & (1UL << i)) != 0) SetPlayerDefeat(i); lastDefeatState = playerDefeatState; lastDefeatStateFrame = frame; } syncForFrame.Add(frame, packet); } } public void DispatchOrdersToClients(Connection conn, int frame, byte[] data) { var from = conn.PlayerIndex; var frameData = CreateFrame(from, frame, data); foreach (var c in Conns.ToList()) if (c != conn && c.Validated) DispatchFrameToClient(c, from, frameData); RecordOrder(frame, data, from); } void RecordOrder(int frame, byte[] data, int from) { recorder?.ReceiveFrame(from, frame, data); if (data.Length > 0 && data[0] == (byte)OrderType.SyncHash) { if (data.Length == Order.SyncHashOrderLength) HandleSyncOrder(frame, data); else Log.Write("server", $"Dropped sync order with length {data.Length} from client {from}. Expected length {Order.SyncHashOrderLength}."); } } public void DispatchServerOrdersToClients(Order order) { DispatchServerOrdersToClients(order.Serialize()); } public void DispatchServerOrdersToClients(byte[] data, int frame = 0) { const int From = 0; var frameData = CreateFrame(From, frame, data); foreach (var c in Conns.ToList()) if (c.Validated) DispatchFrameToClient(c, From, frameData); RecordOrder(frame, data, From); } public void ReceiveOrders(Connection conn, int frame, byte[] data) { // Make sure we don't accidentally forward on orders from clients who we have just dropped if (!Conns.Contains(conn)) return; if (frame == 0) InterpretServerOrders(conn, data); else { // Non-immediate orders must be projected into the future so that all players can // apply them on the same world tick. We can do this directly when forwarding the // packet on to other clients, but sending the same data back to the client that // sent it just to update the frame number would be wasteful. We instead send them // a separate Ack packet that tells them to apply the order from a locally stored queue. // TODO: Replace static latency with a dynamic order buffering system if (data.Length == 0 || data[0] != (byte)OrderType.SyncHash) { frame += OrderLatency; DispatchFrameToClient(conn, conn.PlayerIndex, CreateAckFrame(frame, 1)); orderBuffer.AddOrderTimestamp(conn.PlayerIndex); // Track the last frame for each client so the disconnect handling can write // an EndOfOrders marker with the correct frame number. // TODO: This should be handled by the order buffering system too conn.LastOrdersFrame = frame; } DispatchOrdersToClients(conn, frame, data); } GameSave?.DispatchOrders(conn, frame, data); } void InterpretServerOrders(Connection conn, byte[] data) { var ms = new MemoryStream(data); var br = new BinaryReader(ms); try { while (ms.Position < ms.Length) { var o = Order.Deserialize(null, br); if (o != null) InterpretServerOrder(conn, o); } } catch (EndOfStreamException) { } catch (NotImplementedException) { } } public void SendOrderTo(Connection conn, string order, string data) { DispatchOrdersToClient(conn, 0, 0, Order.FromTargetString(order, data, true).Serialize()); } public void SendMessage(string text) { DispatchServerOrdersToClients(Order.FromTargetString("Message", text, true)); if (Type == ServerType.Dedicated) WriteLineWithTimeStamp(text); } public void SendFluentMessage(string key, params object[] args) { var text = FluentMessage.Serialize(key, args); DispatchServerOrdersToClients(Order.FromTargetString("FluentMessage", text, true)); if (Type == ServerType.Dedicated) WriteLineWithTimeStamp(FluentProvider.GetMessage(key, args)); } public void SendFluentMessageTo(Connection conn, string key, object[] args = null) { var text = FluentMessage.Serialize(key, args); DispatchOrdersToClient(conn, 0, 0, Order.FromTargetString("FluentMessage", text, true).Serialize()); } void WriteLineWithTimeStamp(string line) { Console.WriteLine($"[{DateTime.Now.ToString(Settings.TimestampFormat, CultureInfo.CurrentCulture)}] {line}"); } void InterpretServerOrder(Connection conn, Order o) { lock (LobbyInfo) { // Only accept handshake responses from unvalidated clients // Anything else may be an attempt to exploit the server if (!conn.Validated) { if (o.OrderString == "HandshakeResponse") ValidateClient(conn, o.TargetString, o.OrderString); else { Log.Write("server", $"Rejected connection from {conn.EndPoint}; Order `{o.OrderString}` is not a `HandshakeResponse`."); DropClient(conn); } return; } switch (o.OrderString) { case "Command": { if (!InterpretCommand(o.TargetString, conn)) { Log.Write("server", $"Unknown server command: {o.TargetString}"); SendFluentMessageTo(conn, UnknownServerCommand, new object[] { "command", o.TargetString }); } break; } case "Chat": { if (!IsMultiplayer || !playerMessageTracker.IsPlayerAtFloodLimit(conn)) DispatchOrdersToClients(conn, 0, o.Serialize()); break; } case "GameSaveTraitData": { if (GameSave != null) { var data = MiniYaml.FromString(o.TargetString, o.OrderString)[0]; GameSave.AddTraitData(OpenRA.Exts.ParseInt32Invariant(data.Key), data.Value); } break; } case "CreateGameSave": { if (GameSave != null) { // Sanitize potentially malicious input var filename = o.TargetString; var invalidIndex = -1; var invalidChars = Path.GetInvalidFileNameChars(); while ((invalidIndex = filename.IndexOfAny(invalidChars)) != -1) filename = filename.Remove(invalidIndex, 1); var baseSavePath = Path.Combine( Platform.SupportDir, "Saves", ModData.Manifest.Id, ModData.Manifest.Metadata.Version); if (!Directory.Exists(baseSavePath)) Directory.CreateDirectory(baseSavePath); GameSave.Save(Path.Combine(baseSavePath, filename)); DispatchServerOrdersToClients(Order.FromTargetString("GameSaved", filename, true)); } break; } case "LoadGameSave": { if (Type == ServerType.Dedicated || State >= ServerState.GameStarted) break; // Sanitize potentially malicious input var filename = o.TargetString; var invalidIndex = -1; var invalidChars = Path.GetInvalidFileNameChars(); while ((invalidIndex = filename.IndexOfAny(invalidChars)) != -1) filename = filename.Remove(invalidIndex, 1); var savePath = Path.Combine( Platform.SupportDir, "Saves", ModData.Manifest.Id, ModData.Manifest.Metadata.Version, filename); GameSave = new GameSave(savePath); LobbyInfo.GlobalSettings = GameSave.GlobalSettings; LobbyInfo.Slots = GameSave.Slots; // Reassign clients to slots // - Bot ordering is preserved // - Humans are assigned on a first-come-first-serve basis // - Leftover humans become spectators // Start by removing all bots and assigning all players as spectators foreach (var c in LobbyInfo.Clients) { if (c.Bot != null) LobbyInfo.Clients.Remove(c); else c.Slot = null; } // Rebuild/remap the saved client state // TODO: Multiplayer saves should leave all humans as spectators so they can manually pick slots var adminClientIndex = LobbyInfo.Clients.First(c => c.IsAdmin).Index; foreach (var kv in GameSave.SlotClients) { if (kv.Value.Bot != null) { var bot = new Session.Client() { Index = ChooseFreePlayerIndex(), State = Session.ClientState.NotReady, BotControllerClientIndex = adminClientIndex }; kv.Value.ApplyTo(bot); LobbyInfo.Clients.Add(bot); } else { // This will throw if the server doesn't have enough human clients to fill all player slots // See TODO above - this isn't a problem in practice because MP saves won't use this var client = LobbyInfo.Clients.First(c => c.Slot == null); kv.Value.ApplyTo(client); } } SyncLobbyInfo(); SyncLobbyClients(); break; } } } } public void ReceivePing(Connection conn, int[] pingHistory) { // Levels set relative to the default order lag of 3 net ticks (360ms) // TODO: Adjust this once dynamic lag is implemented var latency = pingHistory.Sum() / pingHistory.Length; var quality = latency < 240 ? Session.ConnectionQuality.Good : latency < 360 ? Session.ConnectionQuality.Moderate : Session.ConnectionQuality.Poor; lock (LobbyInfo) { foreach (var c in LobbyInfo.Clients) if (c.Index == conn.PlayerIndex || (c.Bot != null && c.BotControllerClientIndex == conn.PlayerIndex)) c.ConnectionQuality = quality; // Update ping without forcing a full update // Note that syncing pings doesn't trigger INotifySyncLobbyInfo if (pingUpdated.ElapsedMilliseconds > 5000) { var nodes = new List(); foreach (var c in LobbyInfo.Clients) nodes.Add(new MiniYamlNode($"ConnectionQuality@{c.Index}", FieldSaver.FormatValue(c.ConnectionQuality))); DispatchServerOrdersToClients(Order.FromTargetString("SyncConnectionQuality", nodes.WriteToString(), true)); pingUpdated.Restart(); } } } public Session.Client GetClient(Connection conn) { if (conn == null) return null; return LobbyInfo.ClientWithIndex(conn.PlayerIndex); } public bool HasClientWonOrLost(Session.Client client) => worldPlayers.FirstOrDefault(p => p?.ClientIndex == client.Index)?.Outcome != WinState.Undefined; public void DropClient(Connection toDrop) { lock (LobbyInfo) { orderBuffer?.RemovePlayer(toDrop.PlayerIndex); Conns.Remove(toDrop); var dropClient = LobbyInfo.Clients.FirstOrDefault(c => c.Index == toDrop.PlayerIndex); if (dropClient == null) { toDrop.Dispose(); return; } if (State == ServerState.GameStarted) { if (dropClient.IsObserver) SendFluentMessage(ObserverDisconnected, "player", dropClient.Name); else if (dropClient.Team > 0) SendFluentMessage(PlayerTeamDisconnected, "player", dropClient.Name, "team", dropClient.Team); else SendFluentMessage(PlayerDisconnected, "player", dropClient.Name); } else SendFluentMessage(LobbyDisconnected, "player", dropClient.Name); LobbyInfo.Clients.RemoveAll(c => c.Index == toDrop.PlayerIndex); // Client was the server admin // TODO: Reassign admin for game in progress via an order if (Type == ServerType.Dedicated && dropClient.IsAdmin && State == ServerState.WaitingPlayers) { // Remove any bots controlled by the admin LobbyInfo.Clients.RemoveAll(c => c.Bot != null && c.BotControllerClientIndex == toDrop.PlayerIndex); var nextAdmin = LobbyInfo.Clients.Where(c1 => c1.Bot == null) .MinByOrDefault(c => c.Index); if (nextAdmin != null) { nextAdmin.IsAdmin = true; SendFluentMessage(NewAdmin, "player", nextAdmin.Name); } } var disconnectPacket = new MemoryStream(5); disconnectPacket.WriteByte((byte)OrderType.Disconnect); disconnectPacket.Write(toDrop.PlayerIndex); DispatchServerOrdersToClients(disconnectPacket.ToArray(), toDrop.LastOrdersFrame + 1); if (gameInfo != null) foreach (var player in gameInfo.Players.Where(p => p.ClientIndex == toDrop.PlayerIndex)) player.DisconnectFrame = toDrop.LastOrdersFrame + 1; // All clients have left: clean up if (!Conns.Any(c => c.Validated)) foreach (var t in serverTraits.WithInterface()) t.ServerEmpty(this); if (Conns.Any(c => c.Validated) || Type == ServerType.Dedicated) SyncLobbyClients(); if (Type != ServerType.Dedicated && dropClient.IsAdmin) Shutdown(); } toDrop.Dispose(); } public void SyncLobbyInfo() { lock (LobbyInfo) { if (State == ServerState.WaitingPlayers) // Don't do this while the game is running, it breaks things! DispatchServerOrdersToClients(Order.FromTargetString("SyncInfo", LobbyInfo.Serialize(), true)); foreach (var t in serverTraits.WithInterface()) t.LobbyInfoSynced(this); } } public void SyncLobbyClients() { if (State != ServerState.WaitingPlayers) return; lock (LobbyInfo) { // TODO: Only need to sync the specific client that has changed to avoid conflicts! var clientData = LobbyInfo.Clients.ConvertAll(client => client.Serialize()); DispatchServerOrdersToClients(Order.FromTargetString("SyncLobbyClients", clientData.WriteToString(), true)); foreach (var t in serverTraits.WithInterface()) t.LobbyInfoSynced(this); // The full LobbyInfo includes ping info, so we can delay the next partial ping update // TODO: Replace the special-case ping updates with more general LobbyInfo delta updates pingUpdated.Restart(); } } public void SyncLobbySlots() { if (State != ServerState.WaitingPlayers) return; lock (LobbyInfo) { // TODO: Don't sync all the slots if just one changed! var slotData = LobbyInfo.Slots.Select(slot => slot.Value.Serialize()).ToList(); DispatchServerOrdersToClients(Order.FromTargetString("SyncLobbySlots", slotData.WriteToString(), true)); foreach (var t in serverTraits.WithInterface()) t.LobbyInfoSynced(this); } } public void SyncLobbyGlobalSettings() { if (State != ServerState.WaitingPlayers) return; lock (LobbyInfo) { var sessionData = new List { LobbyInfo.GlobalSettings.Serialize() }; DispatchServerOrdersToClients(Order.FromTargetString("SyncLobbyGlobalSettings", sessionData.WriteToString(), true)); foreach (var t in serverTraits.WithInterface()) t.LobbyInfoSynced(this); } } public void StartGame() { lock (LobbyInfo) { WriteLineWithTimeStamp(FluentProvider.GetMessage(GameStarted)); // Drop any players who are not ready foreach (var c in Conns.Where(c => !c.Validated || GetClient(c).IsInvalid).ToArray()) { SendOrderTo(c, "ServerError", YouWereKicked); DropClient(c); } // Enable game saves for singleplayer missions only // TODO: Enable for multiplayer (non-dedicated servers only) once the lobby UI has been created LobbyInfo.GlobalSettings.GameSavesEnabled = Type != ServerType.Dedicated && LobbyInfo.NonBotClients.Count() == 1; // Player list for win/loss tracking // HACK: NonCombatant and non-Playable players are set to null to simplify replay tracking // The null padding is needed to keep the player indexes in sync with world.Players on the clients // This will need to change if future code wants to use worldPlayers for other purposes var playerRandom = new MersenneTwister(LobbyInfo.GlobalSettings.RandomSeed); foreach (var cmpi in Map.WorldActorInfo.TraitInfos()) cmpi.CreateServerPlayers(Map, LobbyInfo, worldPlayers, playerRandom); gameInfo = new GameInformation() { Mod = Game.ModData.Manifest.Id, Version = Game.ModData.Manifest.Metadata.Version, MapUid = Map.Uid, MapTitle = Map.Title, StartTimeUtc = DateTime.UtcNow, }; // Replay metadata should only include the playable players foreach (var p in worldPlayers) if (p != null) gameInfo.Players.Add(p); if (recorder != null) recorder.Metadata = new ReplayMetadata(gameInfo); SyncLobbyInfo(); var gameSpeeds = Game.ModData.Manifest.Get(); var gameSpeedName = LobbyInfo.GlobalSettings.OptionOrDefault("gamespeed", gameSpeeds.DefaultSpeed); var gameSpeed = gameSpeeds.Speeds[gameSpeedName]; orderBuffer = new OrderBuffer(); orderBuffer.Start(gameSpeed, Conns.Where(c => c.Validated).Select(c => c.PlayerIndex)); State = ServerState.GameStarted; if (IsMultiplayer) OrderLatency = gameSpeed.OrderLatency; if (GameSave == null && LobbyInfo.GlobalSettings.GameSavesEnabled) GameSave = new GameSave(); var startGameData = ""; if (GameSave != null) { GameSave.StartGame(LobbyInfo, Map); if (GameSave.LastOrdersFrame >= 0) { startGameData = new List() { new("SaveLastOrdersFrame", GameSave.LastOrdersFrame.ToStringInvariant()), new("SaveSyncFrame", GameSave.LastSyncFrame.ToStringInvariant()) }.WriteToString(); } } DispatchServerOrdersToClients(Order.FromTargetString("StartGame", startGameData, true)); foreach (var t in serverTraits.WithInterface()) t.GameStarted(this); var firstFrame = 1; if (GameSave != null && GameSave.LastOrdersFrame >= 0) { GameSave.ParseOrders(LobbyInfo, (frame, client, data) => { foreach (var c in Conns) if (c.Validated) DispatchOrdersToClient(c, client, frame, data); }); firstFrame += GameSave.LastOrdersFrame; } // ReceiveOrders projects player orders into the future so that all players can // apply them on the same world tick. // Clients require every frame to have an orders packet associated with it, so we must // inject an empty packet for each frame that we are skipping forwards. // TODO: Replace static latency with a dynamic order buffering system var conns = Conns.Where(c => c.Validated).ToList(); foreach (var from in conns) { for (var i = 0; i < OrderLatency; i++) { from.LastOrdersFrame = firstFrame + i; var frameData = CreateFrame(from.PlayerIndex, from.LastOrdersFrame, Array.Empty()); foreach (var to in conns) DispatchFrameToClient(to, from.PlayerIndex, frameData); RecordOrder(from.LastOrdersFrame, Array.Empty(), from.PlayerIndex); GameSave?.DispatchOrders(from, from.LastOrdersFrame, Array.Empty()); } } } } public bool InterpretCommand(string command, Connection conn) { foreach (var t in serverTraits.WithInterface()) if (t.InterpretCommand(this, conn, GetClient(conn), command)) return true; return false; } public ConnectionTarget GetEndpointForLocalConnection() { var endpoints = new List(); foreach (var listener in listeners) { var endpoint = (IPEndPoint)listener.LocalEndpoint; if (IPAddress.IPv6Any.Equals(endpoint.Address)) endpoints.Add(new DnsEndPoint(IPAddress.IPv6Loopback.ToString(), endpoint.Port)); else if (IPAddress.Any.Equals(endpoint.Address)) endpoints.Add(new DnsEndPoint(IPAddress.Loopback.ToString(), endpoint.Port)); else endpoints.Add(new DnsEndPoint(endpoint.Address.ToString(), endpoint.Port)); } return new ConnectionTarget(endpoints); } public bool MapIsUnknown(string uid) { if (string.IsNullOrEmpty(uid)) return true; var status = ModData.MapCache[uid].Status; return status != MapStatus.Available && status != MapStatus.DownloadAvailable; } public bool MapIsKnown(string uid) { if (string.IsNullOrEmpty(uid)) return false; if (MapPool != null && !MapPool.Contains(uid)) return false; var status = ModData.MapCache[uid].Status; return status == MapStatus.Available || status == MapStatus.DownloadAvailable; } interface IServerEvent { void Invoke(Server server); } sealed class ConnectionConnectEvent : IServerEvent { readonly Socket socket; public ConnectionConnectEvent(Socket socket) { this.socket = socket; } void IServerEvent.Invoke(Server server) { server.AcceptConnection(socket); } } sealed class ConnectionDisconnectEvent : IServerEvent { readonly Connection connection; public ConnectionDisconnectEvent(Connection connection) { this.connection = connection; } void IServerEvent.Invoke(Server server) { server.DropClient(connection); } } sealed class ConnectionPacketEvent : IServerEvent { readonly Connection connection; readonly int frame; readonly byte[] data; public ConnectionPacketEvent(Connection connection, int frame, byte[] data) { this.connection = connection; this.frame = frame; this.data = data; } void IServerEvent.Invoke(Server server) { server.ReceiveOrders(connection, frame, data); } } sealed class ConnectionPingEvent : IServerEvent { readonly Connection connection; readonly int[] pingHistory; // TODO: future net code changes #pragma warning disable IDE0052 readonly byte queueLength; #pragma warning restore IDE0052 public ConnectionPingEvent(Connection connection, int[] pingHistory, byte queueLength) { this.connection = connection; this.pingHistory = pingHistory; this.queueLength = queueLength; } void IServerEvent.Invoke(Server server) { server.ReceivePing(connection, pingHistory); } } sealed class CallbackEvent : IServerEvent { readonly Action action; public CallbackEvent(Action action) { this.action = action; } void IServerEvent.Invoke(Server server) { action(); } } } }