diff --git a/.gitattributes b/.gitattributes index 519d6a5..9a21ff9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -66,3 +66,4 @@ # systemd cannot be trusted with CRLF unit files; keep LF on every platform. ############################################################################### etc/atlasd.service text eol=lf +src/make-matchmaking.sh text eol=lf diff --git a/etc/atlasd.sample.json b/etc/atlasd.sample.json index a6a4087..c386d7b 100644 --- a/etc/atlasd.sample.json +++ b/etc/atlasd.sample.json @@ -108,6 +108,9 @@ "mask_admins_in_broadcasts": false, "mask_admins_in_ban_message": true, "mask_admins_in_kick_message": true, + "name_only_logon": { + "W3DM": true + }, "required_game_key_count": { "D2DV": 1, "D2XP": 2, @@ -118,6 +121,11 @@ "W3XP": 2, "WAR3": 1 }, + "server_signature": { + "modulus": "", + "private_exponent": "", + "address": "" + }, "statstring_updates": { "D2DV": true, "D2XP": true, diff --git a/src/Atlasd/Battlenet/Account.cs b/src/Atlasd/Battlenet/Account.cs index 1a2265a..b8dc4ff 100644 --- a/src/Atlasd/Battlenet/Account.cs +++ b/src/Atlasd/Battlenet/Account.cs @@ -18,6 +18,7 @@ class Account public const string IPAddressKey = "System\\IP"; public const string LastLogoffKey = "System\\Last Logoff"; public const string LastLogonKey = "System\\Last Logon"; + public const string NameOnlyKey = "System\\Name Only"; public const string PasswordKey = "System\\Password Digest"; public const string PortKey = "System\\Port"; public const string ProfileAgeKey = "profile\\age"; @@ -73,6 +74,7 @@ private Account() { new AccountKeyValue(IPAddressKey, IPAddress.Any, AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(LastLogoffKey, DateTime.Now, AccountKeyValue.ReadLevel.Owner, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(LastLogonKey, DateTime.Now, AccountKeyValue.ReadLevel.Owner, AccountKeyValue.WriteLevel.Internal) }, + { new AccountKeyValue(NameOnlyKey, 0, AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(PortKey, 0, AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(PasswordKey, new byte[0], AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(ProfileAgeKey, "", AccountKeyValue.ReadLevel.Any, AccountKeyValue.WriteLevel.Owner) }, diff --git a/src/Atlasd/Battlenet/ClientState.cs b/src/Atlasd/Battlenet/ClientState.cs index adf116c..20e39a2 100644 --- a/src/Atlasd/Battlenet/ClientState.cs +++ b/src/Atlasd/Battlenet/ClientState.cs @@ -280,7 +280,8 @@ protected void ReceiveProtocolBNFTP(SocketAsyncEventArgs e) lock (_receiveLock) { if (ReceiveBuffer.Length == 0) return; - BNFTPState.Receive(ReceiveBuffer); + var consumed = BNFTPState.Receive(ReceiveBuffer); + ReceiveBuffer = ReceiveBuffer[Math.Min(consumed, ReceiveBuffer.Length)..]; } } diff --git a/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs b/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs index 6b3b10c..3f263bc 100644 --- a/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs +++ b/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs @@ -27,17 +27,48 @@ class BNFTPState public UInt32 ClientToken = 0; public GameKey GameKey = null; + private bool AwaitingVersion2Request = false; + public BNFTPState(ClientState client) { Client = client; ServerToken = (uint)new Random().Next(0, 0x7FFFFFFF); } - public void Receive(byte[] buffer) + public int Receive(byte[] buffer) { using var m = new MemoryStream(buffer); using var r = new BinaryReader(m); + if (AwaitingVersion2Request) + { + /** + * ## VERSION 2 <-- ## + * ## Client -> Server ## + * + * (TYPE) (FIELD) (DESCRIPTION) + * UINT32 Starting position Facilitates resuming + * FILETIME Local filetime + * UINT32 Client Token + * UINT32 Key Length + * UINT32 Key's product value + * UINT32 Key's public value + * UINT32 Unknown (Always 0) + * UINT32 [5] CD key hash + * STRING Filename + */ + + FileStartPosition = r.ReadUInt32(); + FileTime = r.ReadUInt64(); + ClientToken = r.ReadUInt32(); + r.ReadBytes(16); + r.ReadBytes(20); + FileName = Encoding.UTF8.GetString(r.ReadByteString()); + + ServeFile(); + return (int)m.Position; + } + /** * ## VERSION 1 <-- ## * ## Client -> Server ## @@ -76,82 +107,8 @@ public void Receive(byte[] buffer) FileTime = r.ReadUInt64(); FileName = Encoding.UTF8.GetString(r.ReadByteString()); - /** - * ## VERSION 1 --> ## - * ## Server -> Client ## - * - * (TYPE) (FIELD) (DESCRIPTION) - * UINT16 Header Length Does not include the file length - * UINT16 Type - * UINT32 File size - * UINT32 Ad Banner ID 0 unless downloading an ad banner - * UINT32 Ad Banner File Extension 0 unless downloading an ad banner - * FILETIME Filetime - * STRING Filename - * VOID File data - */ - - var file = new BNFTP.File(FileName); - if (file == null) - { - Client.Disconnect(); - } - - BinaryReader stream = null; - - bool uploaded = false; - try - { - if (!file.OpenStream()) - { - Client.Disconnect(); - break; - } - - stream = new BinaryReader(file.StreamReader.BaseStream); - stream.BaseStream.Position = Math.Min(stream.BaseStream.Length, FileStartPosition); - - var fileLength = (int)(stream.BaseStream.Length - stream.BaseStream.Position); - HeaderLength = (UInt16)(25 + Encoding.UTF8.GetByteCount(FileName)); - var outBuf = new byte[HeaderLength]; - using var wm = new MemoryStream(outBuf); - using var w = new BinaryWriter(wm); - - w.Write((UInt16)HeaderLength); - w.Write((UInt16)0); // "Type" ??? - w.Write((UInt32)fileLength); - w.Write((UInt32)AdId); - w.Write((UInt32)AdFileExtension); - w.Write((UInt64)new FileInfo(file.Path).LastWriteTimeUtc.ToFileTimeUtc()); - w.Write((string)FileName); - - Write(outBuf); - Write(stream.ReadBytes(fileLength)); - - uploaded = true; - } - catch (Exception ex) - { - if (!(ex is IOException || ex is FileNotFoundException || ex is UnauthorizedAccessException || ex is PathTooLongException)) throw; - - Logging.WriteLine(Logging.LogLevel.Warning, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"{ex.GetType().Name} error encountered for requested file [{FileName}]" + (string.IsNullOrEmpty(ex.Message) ? "" : $"; message: {ex.Message}")); - } - finally - { - if (uploaded) - { - Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"Uploaded file [{FileName}] ({file.Length} bytes)"); - } - - if (stream != null) - { - stream.Close(); - } - - Client.Disconnect(); - } - - break; + ServeFile(); + return (int)m.Position; } case 0x0200: { @@ -164,18 +121,12 @@ public void Receive(byte[] buffer) * UINT32 Product ID See Product Identification * UINT32 Ad Banner ID 0 unless downloading an ad banner * UINT32 Ad Banner File Extension 0 unless downloading an ad banner - * UINT32 File start position For resuming an incomplete download - * FILETIME Filetime - * STRING Filename */ PlatformId = (Platform.PlatformCode)r.ReadUInt32(); ProductId = (Product.ProductCode)r.ReadUInt32(); AdId = r.ReadUInt32(); AdFileExtension = r.ReadUInt32(); - FileStartPosition = r.ReadUInt32(); - FileTime = r.ReadUInt64(); - FileName = Encoding.UTF8.GetString(r.ReadByteString()); /** * ## VERSION 2 --> ## @@ -185,47 +136,97 @@ public void Receive(byte[] buffer) * UINT32 Server Token */ - /** - * ## VERSION 2 <-- ## - * ## Client -> Server ## - * - * (TYPE) (FIELD) (DESCRIPTION) - * UINT32 Starting position Facilitates resuming - * FILETIME Local filetime - * UINT32 Client Token - * UINT32 Key Length - * UINT32 Key's product value - * UINT32 Key's public value - * UINT32 Unknown (Always 0) - * UINT32 [5] CD key hash - * STRING Filename - */ - - /** - * ## VERSION 2 --> ## - * ## Server -> Client ## - * - * (TYPE) (FIELD) (DESCRIPTION) - * UINT16 Header Length Does not include the file length - * UINT32 File size - * UINT32 Ad Banner ID 0 unless downloading an ad banner - * UINT32 Ad Banner File Extension 0 unless downloading an ad banner - * FILETIME Filetime - * STRING Filename - * VOID File data - */ - - break; + AwaitingVersion2Request = true; + Write(BitConverter.GetBytes(ServerToken)); + return (int)m.Position; } default: { Logging.WriteLine(Logging.LogLevel.Warning, Logging.LogType.Client_BNFTP, $"Received unknown BNFTP protocol version [0x{ProtocolVersion:X4}]"); Client.Disconnect("Unknown BNFTP protocol version"); - break; + return buffer.Length; } } } + private void ServeFile() + { + /** + * ## VERSION 1 and 2 --> ## + * ## Server -> Client ## + * + * (TYPE) (FIELD) (DESCRIPTION) + * UINT16 Header Length Does not include the file length + * UINT16 Type + * UINT32 File size + * UINT32 Ad Banner ID 0 unless downloading an ad banner + * UINT32 Ad Banner File Extension 0 unless downloading an ad banner + * FILETIME Filetime + * STRING Filename + * VOID File data + */ + + var file = new BNFTP.File(FileName); + if (file == null) + { + Client.Disconnect(); + } + + BinaryReader stream = null; + + bool uploaded = false; + try + { + if (!file.OpenStream()) + { + Client.Disconnect(); + return; + } + + stream = new BinaryReader(file.StreamReader.BaseStream); + stream.BaseStream.Position = Math.Min(stream.BaseStream.Length, FileStartPosition); + + var fileLength = (int)(stream.BaseStream.Length - stream.BaseStream.Position); + HeaderLength = (UInt16)(25 + Encoding.UTF8.GetByteCount(FileName)); + var outBuf = new byte[HeaderLength]; + using var wm = new MemoryStream(outBuf); + using var w = new BinaryWriter(wm); + + w.Write((UInt16)HeaderLength); + w.Write((UInt16)0); // "Type" ??? + w.Write((UInt32)fileLength); + w.Write((UInt32)AdId); + w.Write((UInt32)AdFileExtension); + w.Write((UInt64)new FileInfo(file.Path).LastWriteTimeUtc.ToFileTimeUtc()); + w.Write((string)FileName); + + Write(outBuf); + Write(stream.ReadBytes(fileLength)); + + uploaded = true; + } + catch (Exception ex) + { + if (!(ex is IOException || ex is FileNotFoundException || ex is UnauthorizedAccessException || ex is PathTooLongException)) throw; + + Logging.WriteLine(Logging.LogLevel.Warning, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"{ex.GetType().Name} error encountered for requested file [{FileName}]" + (string.IsNullOrEmpty(ex.Message) ? "" : $"; message: {ex.Message}")); + } + finally + { + if (uploaded) + { + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"Uploaded file [{FileName}] ({file.Length} bytes)"); + } + + if (stream != null) + { + stream.Close(); + } + + Client.Disconnect(); + } + } + public void Write(byte[] buffer) { Client.Send(buffer); diff --git a/src/Atlasd/Battlenet/Protocols/Game/GameState.cs b/src/Atlasd/Battlenet/Protocols/Game/GameState.cs index 76ee784..4806aa6 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/GameState.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/GameState.cs @@ -56,6 +56,7 @@ public enum LogonTypes : UInt32 public UInt32 PingToken; public UInt32 ProtocolId; public UInt32 ServerToken; + public UInt32 GameSearchCookie; public bool SpawnKey; public byte[] Statstring; public Int32 TimezoneBias; diff --git a/src/Atlasd/Battlenet/Protocols/Game/Message.cs b/src/Atlasd/Battlenet/Protocols/Game/Message.cs index 8fcac44..5846a55 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Message.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Message.cs @@ -79,9 +79,12 @@ public static Message FromByteArray(byte id, byte[] buffer) MessageIds.SID_QUERYREALMS2 => new SID_QUERYREALMS2(buffer), MessageIds.SID_QUERYADURL => new SID_QUERYADURL(buffer), MessageIds.SID_WARCRAFTGENERAL => new SID_WARCRAFTGENERAL(buffer), + MessageIds.SID_NETGAMEPORT => new SID_NETGAMEPORT(buffer), MessageIds.SID_NEWS_INFO => new SID_NEWS_INFO(buffer), MessageIds.SID_AUTH_INFO => new SID_AUTH_INFO(buffer), MessageIds.SID_AUTH_CHECK => new SID_AUTH_CHECK(buffer), + MessageIds.SID_AUTH_ACCOUNTLOGON => new SID_AUTH_ACCOUNTLOGON(buffer), + MessageIds.SID_AUTH_ACCOUNTLOGONPROOF => new SID_AUTH_ACCOUNTLOGONPROOF(buffer), MessageIds.SID_SETEMAIL => new SID_SETEMAIL(buffer), MessageIds.SID_FRIENDSLIST => new SID_FRIENDSLIST(buffer), MessageIds.SID_FRIENDSUPDATE => new SID_FRIENDSUPDATE(buffer), diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs new file mode 100644 index 0000000..c67bacc --- /dev/null +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs @@ -0,0 +1,120 @@ +using Atlasd.Battlenet.Exceptions; +using Atlasd.Daemon; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Atlasd.Battlenet.Protocols.Game.Messages +{ + class SID_AUTH_ACCOUNTLOGON : Message + { + public SID_AUTH_ACCOUNTLOGON() + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGON; + Buffer = new byte[0]; + } + + public SID_AUTH_ACCOUNTLOGON(byte[] buffer) + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGON; + Buffer = buffer; + } + + private static SID_AUTH_ACCOUNTLOGONPROOF.Statuses ToLogonStatus(Account.CreateStatus status) => status switch + { + Account.CreateStatus.UsernameTooShort => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameTooShort, + Account.CreateStatus.UsernameInvalidChars => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameInvalidChars, + Account.CreateStatus.UsernameBannedWord => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameBannedWord, + Account.CreateStatus.UsernameShortAlphanumeric => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameShortAlphanumeric, + Account.CreateStatus.UsernameAdjacentPunctuation => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameAdjacentPunctuation, + Account.CreateStatus.UsernameTooManyPunctuation => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameTooManyPunctuation, + _ => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.BadPassword, + }; + + private static bool Reply(MessageContext context, SID_AUTH_ACCOUNTLOGONPROOF.Statuses status) + { + return new SID_AUTH_ACCOUNTLOGONPROOF().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient, new Dictionary {{ "status", status }})); + } + + public override bool Invoke(MessageContext context) + { + if (context == null || context.Client == null || !context.Client.Connected || context.Client.GameState == null) return false; + + if (context.Direction != MessageDirection.ClientToServer) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} is not supported from the server"); + + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); + + var gameState = context.Client.GameState; + + if (gameState.Product != Product.ProductCode.WarcraftIIIDemo || + !Settings.GetBoolean(new[] { "battlenet", "emulation", "name_only_logon", "W3DM" }, false, true)) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} is only supported for name-only logon (see battlenet.emulation.name_only_logon)"); + + if (Buffer.Length < 34) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} buffer must be at least 34 bytes"); + + if (gameState.ActiveAccount != null) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} cannot be sent after logging into an account"); + + /** + * (UINT8)[32] Client key + * (STRING) Username + * + * The Warcraft III demo has no password field. It sends this message and treats the + * reply to it as SID_AUTH_ACCOUNTLOGONPROOF; it never sends a proof of its own. + */ + + using var m = new MemoryStream(Buffer); + using var r = new BinaryReader(m); + + r.ReadBytes(32); + gameState.Username = r.ReadString(); + + if (!Battlenet.Common.AccountsDb.TryGetValue(gameState.Username, out Account account) || account == null) + { + var createStatus = Account.TryCreate(gameState.Username, new byte[20], out account); + if (createStatus != Account.CreateStatus.Success) + { + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Account [{gameState.Username}] could not be created for name-only logon: {createStatus}"); + return Reply(context, ToLogonStatus(createStatus)); + } + + account.Set(Account.FlagsKey, Account.Flags.None); + account.Set(Account.NameOnlyKey, 1); + } + + var flags = (Account.Flags)account.Get(Account.FlagsKey, Account.Flags.None); + if ((int)account.Get(Account.NameOnlyKey, 0) == 0 || (flags & Account.Flags.Closed) != 0) + { + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Account [{gameState.Username}] cannot be used for name-only logon"); + return Reply(context, SID_AUTH_ACCOUNTLOGONPROOF.Statuses.BadPassword); + } + + gameState.ActiveAccount = account; + gameState.FailedLogons = (UInt32)account.Get(Account.FailedLogonsKey, (UInt32)0); + gameState.LastLogon = (DateTime)account.Get(Account.LastLogonKey, DateTime.Now); + + account.Set(Account.IPAddressKey, context.Client.RemoteEndPoint.ToString().Split(":")[0]); + account.Set(Account.LastLogonKey, DateTime.Now); + account.Set(Account.PortKey, context.Client.RemoteEndPoint.ToString().Split(":")[1]); + + var serial = 1; + var onlineName = gameState.Username; + while (!Battlenet.Common.ActiveAccounts.TryAdd(onlineName, account)) onlineName = $"{gameState.Username}#{++serial}"; + gameState.OnlineName = onlineName; + + gameState.Username = (string)account.Get(Account.UsernameKey, gameState.Username); + + if (!Battlenet.Common.ActiveGameStates.TryAdd(gameState.OnlineName, gameState)) + { + Logging.WriteLine(Logging.LogLevel.Error, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Failed to add game state to active game state cache"); + Battlenet.Common.ActiveAccounts.TryRemove(onlineName, out _); + return false; + } + + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Account [{gameState.Username}] logon success as [{gameState.OnlineName}]"); + return Reply(context, SID_AUTH_ACCOUNTLOGONPROOF.Statuses.Success); + } + } +} diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs new file mode 100644 index 0000000..0158460 --- /dev/null +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs @@ -0,0 +1,60 @@ +using Atlasd.Daemon; +using System; +using System.IO; + +namespace Atlasd.Battlenet.Protocols.Game.Messages +{ + class SID_AUTH_ACCOUNTLOGONPROOF : Message + { + public enum Statuses : UInt32 + { + Success = 0x00, + BadPassword = 0x02, + UsernameTooShort = 0x07, + UsernameInvalidChars = 0x08, + UsernameBannedWord = 0x09, + UsernameShortAlphanumeric = 0x0A, + UsernameAdjacentPunctuation = 0x0B, + UsernameTooManyPunctuation = 0x0C, + } + + public SID_AUTH_ACCOUNTLOGONPROOF() + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGONPROOF; + Buffer = new byte[0]; + } + + public SID_AUTH_ACCOUNTLOGONPROOF(byte[] buffer) + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGONPROOF; + Buffer = buffer; + } + + public override bool Invoke(MessageContext context) + { + if (context == null || context.Client == null || !context.Client.Connected || context.Client.GameState == null) return false; + + if (context.Direction != MessageDirection.ServerToClient) + throw new Exceptions.GameProtocolViolationException(context.Client, $"{MessageName(Id)} is not supported from clients"); + + /** + * (UINT32) Status + * (UINT8)[20] Server password proof + */ + + var status = (Statuses)context.Arguments["status"]; + + Buffer = new byte[24]; + + using var m = new MemoryStream(Buffer); + using var w = new BinaryWriter(m); + + w.Write((UInt32)status); + w.Write(new byte[20]); + + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes) (status: 0x{(UInt32)status:X8})"); + context.Client.Send(ToByteArray(context.Client.ProtocolType)); + return true; + } + } +} diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs index 4df798b..8e2e04d 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs @@ -61,6 +61,9 @@ public override bool Invoke(MessageContext context) context.Client.GameState.Locale.CountryNameAbbreviated = r.ReadString(); context.Client.GameState.Locale.CountryName = r.ReadString(); + if (context.Client.GameState.Product == Product.ProductCode.WarcraftIIIDemo) + context.Client.GameState.LogonType = GameState.LogonTypes.NLS; + return new SID_PING().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient, new Dictionary(){{ "token", context.Client.GameState.PingToken }})) && new SID_AUTH_INFO().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient)); } @@ -108,7 +111,7 @@ public override bool Invoke(MessageContext context) w.Write((byte)0); if (Product.IsWarcraftIII(context.Client.GameState.Product)) - w.Write(new byte[128]); + w.Write(ServerSignature.Create(context.Client)); context.Client.GameState.SetLocale(); diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs new file mode 100644 index 0000000..861103d --- /dev/null +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs @@ -0,0 +1,45 @@ +using Atlasd.Battlenet.Exceptions; +using Atlasd.Daemon; +using System; +using System.IO; + +namespace Atlasd.Battlenet.Protocols.Game.Messages +{ + class SID_NETGAMEPORT : Message + { + public SID_NETGAMEPORT() + { + Id = (byte)MessageIds.SID_NETGAMEPORT; + Buffer = new byte[0]; + } + + public SID_NETGAMEPORT(byte[] buffer) + { + Id = (byte)MessageIds.SID_NETGAMEPORT; + Buffer = buffer; + } + + public override bool Invoke(MessageContext context) + { + if (context == null || context.Client == null || !context.Client.Connected || context.Client.GameState == null) return false; + + if (context.Direction != MessageDirection.ClientToServer) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} is a client-to-server message"); + + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); + + if (Buffer.Length != 2) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} buffer must be 2 bytes"); + + /** + * (UINT16) Port + */ + + using var m = new MemoryStream(Buffer); + using var r = new BinaryReader(m); + + context.Client.GameState.GameDataPort = r.ReadUInt16(); + return true; + } + } +} diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs index c3beb2f..5ae1fc8 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs @@ -69,10 +69,10 @@ public override bool Invoke(MessageContext context) using var w = new BinaryWriter(m); w.Write((byte)1); - w.Write((UInt32)(lastLogon.ToFileTimeUtc() >> 32)); - w.Write((UInt32)(newsTimestamp.ToFileTimeUtc() >> 32)); - w.Write((UInt32)(newsTimestamp.ToFileTimeUtc() >> 32)); - w.Write((UInt32)(newsTimestamp.ToFileTimeUtc() >> 32)); + w.Write((UInt32)new DateTimeOffset(lastLogon).ToUnixTimeSeconds()); + w.Write((UInt32)new DateTimeOffset(newsTimestamp).ToUnixTimeSeconds()); + w.Write((UInt32)new DateTimeOffset(newsTimestamp).ToUnixTimeSeconds()); + w.Write((UInt32)new DateTimeOffset(newsTimestamp).ToUnixTimeSeconds()); w.Write((string)newsGreeting); Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs index cfb8e19..84d3386 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs @@ -32,8 +32,30 @@ public SID_WARCRAFTGENERAL(byte[] buffer) Buffer = buffer; } + private static bool Reply(MessageContext context, byte subcommand, UInt32? status) + { + var buffer = new byte[status.HasValue ? 9 : 5]; + using (var m = new MemoryStream(buffer)) + using (var w = new BinaryWriter(m)) + { + w.Write(subcommand); + w.Write(context.Client.GameState.GameSearchCookie); + if (status.HasValue) w.Write(status.Value); + } + + return new SID_WARCRAFTGENERAL().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient, new Dictionary {{ "buffer", buffer }})); + } + public override bool Invoke(MessageContext context) { + if (context.Direction == MessageDirection.ServerToClient) + { + Buffer = (byte[])context.Arguments["buffer"]; + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} subcommand {Buffer[0]:X2} ({4 + Buffer.Length} bytes)"); + context.Client.Send(ToByteArray(context.Client.ProtocolType)); + return true; + } + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); if (context.Client.GameState == null || !Product.IsWarcraftIII(context.Client.GameState.Product)) @@ -45,11 +67,40 @@ public override bool Invoke(MessageContext context) byte subcommand; using (var m = new MemoryStream(Buffer)) using (var r = new BinaryReader(m)) + { subcommand = r.ReadByte(); - Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} received subcommand {subcommand:X2}"); + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} received subcommand {subcommand:X2}"); + + switch ((SubCommands)subcommand) + { + case SubCommands.WID_GAMESEARCH: + { + /** + * Warcraft III demo layout: + * (UINT8) Subcommand + * (UINT32) Cookie + * (UINT32) Unknown + * (UINT8) Unknown + * (UINT8) Unknown + * (UINT32) Unknown + * (UINT8) Length of the remaining data (8) + * (UINT32) Tick count + * (UINT32) Race + */ + + if (Buffer.Length < 16) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} game search buffer must be at least 16 bytes"); + + context.Client.GameState.GameSearchCookie = r.ReadUInt32(); + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Game search requested (cookie 0x{context.Client.GameState.GameSearchCookie:X8}): {BitConverter.ToString(Buffer)}"); - // TODO: Compare subcommand variable with SubCommands enum and do procedures, for now just ignore + return Reply(context, subcommand, 0); + } + case SubCommands.WID_CANCELSEARCH: + return Reply(context, subcommand, null); + } + } return true; } diff --git a/src/Atlasd/Battlenet/ServerSignature.cs b/src/Atlasd/Battlenet/ServerSignature.cs new file mode 100644 index 0000000..f6ef2ab --- /dev/null +++ b/src/Atlasd/Battlenet/ServerSignature.cs @@ -0,0 +1,46 @@ +using Atlasd.Daemon; +using System; +using System.Globalization; +using System.Net; +using System.Numerics; + +namespace Atlasd.Battlenet +{ + class ServerSignature + { + public const int Length = 128; + + public static byte[] Create(ClientState client) + { + var signature = new byte[Length]; + + var modulusHex = Settings.GetString(new[] { "battlenet", "emulation", "server_signature", "modulus" }, "", true); + var exponentHex = Settings.GetString(new[] { "battlenet", "emulation", "server_signature", "private_exponent" }, "", true); + if (string.IsNullOrEmpty(modulusHex) || string.IsNullOrEmpty(exponentHex)) return signature; + + var addressText = Settings.GetString(new[] { "battlenet", "emulation", "server_signature", "address" }, "", true); + IPAddress address; + if (!string.IsNullOrEmpty(addressText)) address = IPAddress.Parse(addressText); + else if (client.Socket?.LocalEndPoint is IPEndPoint localEndPoint) address = localEndPoint.Address.MapToIPv4(); + else return signature; + + var modulus = BigInteger.Parse("0" + modulusHex, NumberStyles.HexNumber); + var exponent = BigInteger.Parse("0" + exponentHex, NumberStyles.HexNumber); + + var block = new byte[Length]; + Array.Fill(block, (byte)0xBB); + address.GetAddressBytes().CopyTo(block, 0); + block[Length - 1] = 0x0B; + + var message = new BigInteger(block, isUnsigned: true); + if (message >= modulus) + { + Logging.WriteLine(Logging.LogLevel.Error, Logging.LogType.Config, "Setting [battlenet] -> [emulation] -> [server_signature] -> [modulus] is too small to sign with; check value"); + return signature; + } + + BigInteger.ModPow(message, exponent, modulus).ToByteArray(isUnsigned: true).CopyTo(signature, 0); + return signature; + } + } +} diff --git a/src/bnftptest.py b/src/bnftptest.py new file mode 100644 index 0000000..b1c362f --- /dev/null +++ b/src/bnftptest.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Fetches one file from a running Atlas over BNFTP version 1 and version 2 and checks that both succeed with the same content. + +usage: bnftptest.py [host] [port] [filename] +""" +import socket +import sys +from struct import pack, unpack_from + +PLATFORM_IX86 = 0x49583836 +PRODUCT_W3DM = 0x5733444d + +host = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' +port = int(sys.argv[2]) if len(sys.argv) > 2 else 6112 +name = (sys.argv[3] if len(sys.argv) > 3 else 'ver-IX86-1.mpq').encode() + b'\0' + + +def read_all(sock): + data = b'' + while True: + chunk = sock.recv(65536) + if not chunk: + return data + data += chunk + + +def parse_reply(data): + header_length, type_, size = unpack_from(' "$work/make-matchmaking.cs" <<'CSHARP' +using System.Text; + +var maps = new[] { (Path: @"Maps\(4)Deadlock.w3m", Unknown: 0u) }; + +var gameTypes = new[] +{ + (Id: (byte)0, Styles: new[] { (Id: (byte)0, Name: "Solo", Maps: maps, Description: "One versus one") }), + (Id: (byte)2, Styles: new[] { (Id: (byte)0, Name: "2 vs 2", Maps: maps, Description: "Two versus two") }), + (Id: (byte)3, Styles: new[] { (Id: (byte)0, Name: "3 vs 3", Maps: maps, Description: "Three versus three") }), + (Id: (byte)4, Styles: new[] { (Id: (byte)0, Name: "4 vs 4", Maps: maps, Description: "Four versus four") }), +}; + +using var stream = new MemoryStream(); +using var writer = new BinaryWriter(stream); + +void WriteString(string value) +{ + writer.Write(Encoding.ASCII.GetBytes(value)); + writer.Write((byte)0); +} + +writer.Write(1u); +writer.Write((byte)gameTypes.Length); + +foreach (var gameType in gameTypes) +{ + writer.Write(gameType.Id); + writer.Write((byte)gameType.Styles.Length); + + foreach (var style in gameType.Styles) + { + writer.Write(style.Id); + WriteString(style.Name); + writer.Write((byte)style.Maps.Length); + + foreach (var map in style.Maps) + { + WriteString(map.Path); + writer.Write(map.Unknown); + } + + WriteString(style.Description); + } +} + +File.WriteAllBytes(args[0], stream.ToArray()); +Console.WriteLine($"{stream.Length} bytes written to {args[0]}"); +CSHARP + +dotnet run "$work/make-matchmaking.cs" -- "$output" diff --git a/var/bnftp/matchmaking-w3dm-enUS.dat b/var/bnftp/matchmaking-w3dm-enUS.dat new file mode 100644 index 0000000..a9bc4af Binary files /dev/null and b/var/bnftp/matchmaking-w3dm-enUS.dat differ diff --git a/var/bnftp/termsofservice-enUS.txt b/var/bnftp/termsofservice-enUS.txt new file mode 100644 index 0000000..dc0b896 --- /dev/null +++ b/var/bnftp/termsofservice-enUS.txt @@ -0,0 +1 @@ +Atlas test server. By continuing you agree to nothing in particular.