From 1124521cb89c99edb788af60ca75a3ae1a32a526 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Wed, 18 May 2022 16:39:48 +0100 Subject: [PATCH] TcpDotNet.Says("Hello World"); --- .gitignore | 37 +++ TcpDotNet.ClientIntegrationTest/Program.cs | 26 +++ .../TcpDotNet.ClientIntegrationTest.csproj | 14 ++ TcpDotNet.ListenerIntegrationTest/Program.cs | 29 +++ .../TcpDotNet.ListenerIntegrationTest.csproj | 14 ++ TcpDotNet.sln | 28 +++ TcpDotNet/BaseClientNode.cs | 142 ++++++++++++ TcpDotNet/DisconnectReason.cs | 17 ++ TcpDotNet/DisconnectedException.cs | 5 + .../EventData/ClientConnectedEventArgs.cs | 22 ++ .../EventData/ClientDisconnectedEventArgs.cs | 30 +++ .../ClientPacketReceivedEventArgs.cs | 32 +++ TcpDotNet/Node.cs | 103 +++++++++ TcpDotNet/Protocol/Packet.cs | 39 ++++ TcpDotNet/Protocol/PacketAttribute.cs | 23 ++ TcpDotNet/Protocol/PacketHandler.cs | 55 +++++ .../Packets/ClientBound/PongPacket.cs | 40 ++++ .../ServerBound/HandshakeRequestPacket.cs | 41 ++++ .../Packets/ServerBound/PingPacket.cs | 39 ++++ TcpDotNet/Protocol/ProtocolReader.cs | 215 ++++++++++++++++++ TcpDotNet/Protocol/ProtocolWriter.cs | 165 ++++++++++++++ TcpDotNet/ProtocolClient.cs | 68 ++++++ TcpDotNet/ProtocolListener.Client.cs | 63 +++++ TcpDotNet/ProtocolListener.cs | 125 ++++++++++ TcpDotNet/TcpDotNet.csproj | 10 + 25 files changed, 1382 insertions(+) create mode 100644 .gitignore create mode 100644 TcpDotNet.ClientIntegrationTest/Program.cs create mode 100644 TcpDotNet.ClientIntegrationTest/TcpDotNet.ClientIntegrationTest.csproj create mode 100644 TcpDotNet.ListenerIntegrationTest/Program.cs create mode 100644 TcpDotNet.ListenerIntegrationTest/TcpDotNet.ListenerIntegrationTest.csproj create mode 100644 TcpDotNet.sln create mode 100644 TcpDotNet/BaseClientNode.cs create mode 100644 TcpDotNet/DisconnectReason.cs create mode 100644 TcpDotNet/DisconnectedException.cs create mode 100644 TcpDotNet/EventData/ClientConnectedEventArgs.cs create mode 100644 TcpDotNet/EventData/ClientDisconnectedEventArgs.cs create mode 100644 TcpDotNet/EventData/ClientPacketReceivedEventArgs.cs create mode 100644 TcpDotNet/Node.cs create mode 100644 TcpDotNet/Protocol/Packet.cs create mode 100644 TcpDotNet/Protocol/PacketAttribute.cs create mode 100644 TcpDotNet/Protocol/PacketHandler.cs create mode 100644 TcpDotNet/Protocol/Packets/ClientBound/PongPacket.cs create mode 100644 TcpDotNet/Protocol/Packets/ServerBound/HandshakeRequestPacket.cs create mode 100644 TcpDotNet/Protocol/Packets/ServerBound/PingPacket.cs create mode 100644 TcpDotNet/Protocol/ProtocolReader.cs create mode 100644 TcpDotNet/Protocol/ProtocolWriter.cs create mode 100644 TcpDotNet/ProtocolClient.cs create mode 100644 TcpDotNet/ProtocolListener.Client.cs create mode 100644 TcpDotNet/ProtocolListener.cs create mode 100644 TcpDotNet/TcpDotNet.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a437a65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ diff --git a/TcpDotNet.ClientIntegrationTest/Program.cs b/TcpDotNet.ClientIntegrationTest/Program.cs new file mode 100644 index 0000000..98d2eed --- /dev/null +++ b/TcpDotNet.ClientIntegrationTest/Program.cs @@ -0,0 +1,26 @@ +using System.Net; +using TcpDotNet; +using TcpDotNet.Protocol; +using TcpDotNet.Protocol.Packets.ClientBound; +using TcpDotNet.Protocol.Packets.ServerBound; + +using var client = new ProtocolClient(); +client.RegisterPacketHandler(PacketHandler.Empty); +await client.ConnectAsync(IPAddress.IPv6Loopback, 1234); + +Console.WriteLine($"Connected to {client.RemoteEndPoint}"); +Console.WriteLine("Sending ping packet..."); +var ping = new PingPacket(); +await client.SendPacketAsync(ping); + +Console.WriteLine("Waiting for response..."); +Packet? response = await client.ReadNextPacketAsync(); +if (response is PongPacket pong) +{ + Console.WriteLine("Received pong packet"); + Console.WriteLine(pong.Payload.SequenceEqual(ping.Payload) ? "Payload matches" : "Payload does not match"); +} +else +{ + Console.WriteLine("Received unknown packet"); +} diff --git a/TcpDotNet.ClientIntegrationTest/TcpDotNet.ClientIntegrationTest.csproj b/TcpDotNet.ClientIntegrationTest/TcpDotNet.ClientIntegrationTest.csproj new file mode 100644 index 0000000..fa69423 --- /dev/null +++ b/TcpDotNet.ClientIntegrationTest/TcpDotNet.ClientIntegrationTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + diff --git a/TcpDotNet.ListenerIntegrationTest/Program.cs b/TcpDotNet.ListenerIntegrationTest/Program.cs new file mode 100644 index 0000000..c53ec68 --- /dev/null +++ b/TcpDotNet.ListenerIntegrationTest/Program.cs @@ -0,0 +1,29 @@ +using TcpDotNet; +using TcpDotNet.Protocol; +using TcpDotNet.Protocol.Packets.ClientBound; +using TcpDotNet.Protocol.Packets.ServerBound; + +var listener = new ProtocolListener(); +listener.Started += (_, _) => + Console.WriteLine($"Listener started on {listener.LocalEndPoint}"); + +listener.ClientConnected += (_, e) => + Console.WriteLine($"Client connected from {e.Client.RemoteEndPoint} with session {e.Client.SessionId}"); + +listener.ClientDisconnected += (_, e) => + Console.WriteLine($"Client {e.Client.SessionId} disconnected ({e.DisconnectReason})"); + +listener.RegisterPacketHandler(new PingPacketHandler()); +listener.Start(1234); + +await Task.Delay(-1); + +internal sealed class PingPacketHandler : PacketHandler +{ + public override async Task HandleAsync(BaseClientNode recipient, PingPacket packet) + { + Console.WriteLine($"Client {recipient.SessionId} sent ping with payload {BitConverter.ToString(packet.Payload)}"); + var pong = new PongPacket(packet.Payload); + await recipient.SendPacketAsync(pong); + } +} diff --git a/TcpDotNet.ListenerIntegrationTest/TcpDotNet.ListenerIntegrationTest.csproj b/TcpDotNet.ListenerIntegrationTest/TcpDotNet.ListenerIntegrationTest.csproj new file mode 100644 index 0000000..fa69423 --- /dev/null +++ b/TcpDotNet.ListenerIntegrationTest/TcpDotNet.ListenerIntegrationTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + diff --git a/TcpDotNet.sln b/TcpDotNet.sln new file mode 100644 index 0000000..1a518a6 --- /dev/null +++ b/TcpDotNet.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcpDotNet", "TcpDotNet\TcpDotNet.csproj", "{AB572C69-102C-4572-B530-EDDF051AC571}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcpDotNet.ListenerIntegrationTest", "TcpDotNet.ListenerIntegrationTest\TcpDotNet.ListenerIntegrationTest.csproj", "{FED8DEE4-295C-4C2A-80D4-DDA23C45912A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcpDotNet.ClientIntegrationTest", "TcpDotNet.ClientIntegrationTest\TcpDotNet.ClientIntegrationTest.csproj", "{ED9C812F-9835-4268-9AFC-57CFAAC16162}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AB572C69-102C-4572-B530-EDDF051AC571}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB572C69-102C-4572-B530-EDDF051AC571}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB572C69-102C-4572-B530-EDDF051AC571}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB572C69-102C-4572-B530-EDDF051AC571}.Release|Any CPU.Build.0 = Release|Any CPU + {FED8DEE4-295C-4C2A-80D4-DDA23C45912A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FED8DEE4-295C-4C2A-80D4-DDA23C45912A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FED8DEE4-295C-4C2A-80D4-DDA23C45912A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FED8DEE4-295C-4C2A-80D4-DDA23C45912A}.Release|Any CPU.Build.0 = Release|Any CPU + {ED9C812F-9835-4268-9AFC-57CFAAC16162}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED9C812F-9835-4268-9AFC-57CFAAC16162}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED9C812F-9835-4268-9AFC-57CFAAC16162}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED9C812F-9835-4268-9AFC-57CFAAC16162}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/TcpDotNet/BaseClientNode.cs b/TcpDotNet/BaseClientNode.cs new file mode 100644 index 0000000..2fbb723 --- /dev/null +++ b/TcpDotNet/BaseClientNode.cs @@ -0,0 +1,142 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Security.Cryptography; +using TcpDotNet.Protocol; + +namespace TcpDotNet; + +/// +/// Represents a client node. +/// +public abstract class BaseClientNode : Node +{ + /// + /// Gets a value indicating whether the client is connected. + /// + /// if the client is connected; otherwise, . + public bool IsConnected { get; protected set; } + + /// + /// Gets the remote endpoint. + /// + /// The with which the client is communicating. + /// An error occurred when attempting to access the socket. + /// has been closed. + public EndPoint RemoteEndPoint => BaseSocket.RemoteEndPoint; + + /// + /// Gets the session ID of the client. + /// + /// The session ID. + public Guid SessionId { get; internal set; } + + /// + /// Gets or sets a value indicating whether GZip compression is enabled. + /// + /// if compression is enabled; otherwise, . + internal bool UseCompression { get; set; } = true; + + /// + /// Gets or sets a value indicating whether encryption is enabled. + /// + /// if encryption is enabled; otherwise, . + internal bool UseEncryption { get; set; } = false; + + /// + /// Gets the AES implementation used by this client. + /// + /// The AES implementation. + internal Aes Aes { get; } = Aes.Create(); + + /// + /// Reads the next packet from the client's stream. + /// + /// The next packet, or if no valid packet was read. + public async Task ReadNextPacketAsync() + { + await using var networkStream = new NetworkStream(BaseSocket); + using var networkReader = new ProtocolReader(networkStream); + int length; + try + { + length = networkReader.ReadInt32(); + } + catch (EndOfStreamException) + { + throw new DisconnectedException(); + } + + var buffer = new MemoryStream(); + Stream targetStream = buffer; + buffer.Write(networkReader.ReadBytes(length)); + buffer.Position = 0; + + if (UseCompression) targetStream = new GZipStream(targetStream, CompressionMode.Decompress); + if (UseEncryption) targetStream = new CryptoStream(targetStream, Aes.CreateDecryptor(), CryptoStreamMode.Read); + + using var bufferReader = new ProtocolReader(targetStream); + int packetHeader = bufferReader.ReadInt32(); + + if (!RegisteredPackets.TryGetValue(packetHeader, out Type? packetType)) + { + Console.WriteLine($"Unknown packet {packetHeader:X8}"); + return null; + } + + const BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + ConstructorInfo? constructor = + packetType.GetConstructors(bindingFlags).FirstOrDefault(c => c.GetParameters().Length == 0); + + if (constructor is null) + return null; + + var packet = (Packet) constructor.Invoke(null); + await packet.DeserializeAsync(bufferReader); + await targetStream.DisposeAsync(); + + if (RegisteredPacketHandlers.TryGetValue(packetType, out PacketHandler? packetHandler)) + await packetHandler.HandleAsync(this, packet); + + return packet; + } + + /// + /// Sends a packet to the remote endpoint. + /// + /// The packet to send. + /// The type of the packet. + public async Task SendPacketAsync(TPacket packet) + where TPacket : Packet + { + var buffer = new MemoryStream(); + Stream targetStream = buffer; + + if (UseEncryption) targetStream = new CryptoStream(targetStream, Aes.CreateEncryptor(), CryptoStreamMode.Write); + if (UseCompression) targetStream = new GZipStream(targetStream, CompressionMode.Compress); + + await using var bufferWriter = new ProtocolWriter(targetStream); + bufferWriter.Write(packet.Id); + await packet.SerializeAsync(bufferWriter); + + switch (targetStream) + { + case CryptoStream cryptoStream: + cryptoStream.FlushFinalBlock(); + break; + case GZipStream {BaseStream: CryptoStream baseCryptoStream}: + baseCryptoStream.FlushFinalBlock(); + break; + } + + await targetStream.FlushAsync(); + buffer.Position = 0; + + await using var networkStream = new NetworkStream(BaseSocket); + await using var networkWriter = new ProtocolWriter(networkStream); + networkWriter.Write((int) buffer.Length); + await buffer.CopyToAsync(networkStream); + await networkStream.FlushAsync(); + } +} \ No newline at end of file diff --git a/TcpDotNet/DisconnectReason.cs b/TcpDotNet/DisconnectReason.cs new file mode 100644 index 0000000..9d78ccc --- /dev/null +++ b/TcpDotNet/DisconnectReason.cs @@ -0,0 +1,17 @@ +namespace TcpDotNet; + +/// +/// An enumeration of disconnect reasons. +/// +public enum DisconnectReason +{ + /// + /// The client disconnected gracefully. + /// + Disconnect, + + /// + /// The client reached an unexpected end of stream. + /// + EndOfStream +} diff --git a/TcpDotNet/DisconnectedException.cs b/TcpDotNet/DisconnectedException.cs new file mode 100644 index 0000000..c9e130a --- /dev/null +++ b/TcpDotNet/DisconnectedException.cs @@ -0,0 +1,5 @@ +namespace TcpDotNet; + +internal sealed class DisconnectedException : Exception +{ +} diff --git a/TcpDotNet/EventData/ClientConnectedEventArgs.cs b/TcpDotNet/EventData/ClientConnectedEventArgs.cs new file mode 100644 index 0000000..ed3a43e --- /dev/null +++ b/TcpDotNet/EventData/ClientConnectedEventArgs.cs @@ -0,0 +1,22 @@ +namespace TcpDotNet.EventData; + +/// +/// Provides event information for . +/// +public sealed class ClientConnectedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The client which connected. + public ClientConnectedEventArgs(ProtocolListener.Client client) + { + Client = client; + } + + /// + /// Gets the client which connected. + /// + /// The connected client. + public ProtocolListener.Client Client { get; } +} diff --git a/TcpDotNet/EventData/ClientDisconnectedEventArgs.cs b/TcpDotNet/EventData/ClientDisconnectedEventArgs.cs new file mode 100644 index 0000000..98858e5 --- /dev/null +++ b/TcpDotNet/EventData/ClientDisconnectedEventArgs.cs @@ -0,0 +1,30 @@ +namespace TcpDotNet.EventData; + +/// +/// Provides event information for . +/// +public sealed class ClientDisconnectedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The client which disconnected. + /// The reason for the disconnect. + public ClientDisconnectedEventArgs(ProtocolListener.Client client, DisconnectReason disconnectReason) + { + Client = client; + DisconnectReason = disconnectReason; + } + + /// + /// Gets the client which connected. + /// + /// The connected client. + public ProtocolListener.Client Client { get; } + + /// + /// Gets the reason for the disconnect. + /// + /// The reason for the disconnect. + public DisconnectReason DisconnectReason { get; } +} diff --git a/TcpDotNet/EventData/ClientPacketReceivedEventArgs.cs b/TcpDotNet/EventData/ClientPacketReceivedEventArgs.cs new file mode 100644 index 0000000..bb2e487 --- /dev/null +++ b/TcpDotNet/EventData/ClientPacketReceivedEventArgs.cs @@ -0,0 +1,32 @@ +using TcpDotNet.Protocol; + +namespace TcpDotNet.EventData; + +/// +/// Provides event information for . +/// +public sealed class ClientPacketReceivedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The client who sent the packet. + /// The deserialized packet. + public ClientPacketReceivedEventArgs(ProtocolListener.Client client, Packet packet) + { + Client = client; + Packet = packet; + } + + /// + /// Gets the client who sent the packet. + /// + /// The sender. + public ProtocolListener.Client Client { get; } + + /// + /// Gets the packet which was sent. + /// + /// The packet. + public Packet Packet { get; } +} diff --git a/TcpDotNet/Node.cs b/TcpDotNet/Node.cs new file mode 100644 index 0000000..436123a --- /dev/null +++ b/TcpDotNet/Node.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Net.Sockets; +using System.Reflection; +using TcpDotNet.Protocol; + +namespace TcpDotNet; + +/// +/// Represents a TCP node. +/// +public abstract class Node : IDisposable +{ + private readonly ConcurrentDictionary _registeredPackets = new(); + private readonly ConcurrentDictionary _registeredPacketHandlers = new(); + + /// + /// Gets the underlying socket for this node. + /// + /// The underlying socket. + public Socket BaseSocket { get; protected set; } = new(SocketType.Stream, ProtocolType.Tcp); + + /// + /// Gets the registered packets for this node. + /// + /// The registered packets. + public IReadOnlyDictionary RegisteredPackets => + new ReadOnlyDictionary(_registeredPackets); + + /// + /// Gets the registered packets for this node. + /// + /// The registered packets. + public IReadOnlyDictionary RegisteredPacketHandlers => + new ReadOnlyDictionary(_registeredPacketHandlers); + + /// + public void Dispose() + { + BaseSocket.Dispose(); + } + + /// + /// Registers a packet handler. + /// + /// The type of the packet to handle. + /// The handler to register. + /// + /// or is . + /// + /// The type of is not a valid packet. + public void RegisterPacketHandler(Type packetType, PacketHandler handler) + { + if (packetType is null) throw new ArgumentNullException(nameof(packetType)); + if (handler is null) throw new ArgumentNullException(nameof(handler)); + RegisterPacket(packetType); + _registeredPacketHandlers.TryAdd(packetType, handler); + } + + /// + /// Registers a packet handler. + /// + /// The handler to register. + /// The type of the packet. + /// is . + /// The type of is not a valid packet. + public void RegisterPacketHandler(PacketHandler handler) + where TPacket : Packet + { + if (handler is null) throw new ArgumentNullException(nameof(handler)); + RegisterPacket(); + _registeredPacketHandlers.TryAdd(typeof(TPacket), handler); + } + + /// + /// Registers a packet. + /// + /// The type of the packet. + /// The type of is not a valid packet. + internal void RegisterPacket() + { + RegisterPacket(typeof(TPacket)); + } + + /// + /// Registers a packet. + /// + /// The type of the packet. + /// The type of is not a valid packet. + internal void RegisterPacket(Type packetType) + { + if (_registeredPackets.Values.Contains(packetType)) return; + if (!packetType.IsSubclassOf(typeof(Packet))) + throw new ArgumentException("The type of the packet is not a valid packet.", nameof(packetType)); + + var attribute = packetType.GetCustomAttribute(); + if (attribute is null) throw new ArgumentException($"{packetType.Name} is not a valid packet."); + if (_registeredPackets.TryGetValue(attribute.Id, out Type? registeredPacket)) + throw new ArgumentException($"The packet type {attribute.Id:X8} is already registered to {registeredPacket.Name}."); + + _registeredPackets.TryAdd(attribute.Id, packetType); + } +} diff --git a/TcpDotNet/Protocol/Packet.cs b/TcpDotNet/Protocol/Packet.cs new file mode 100644 index 0000000..c7ee131 --- /dev/null +++ b/TcpDotNet/Protocol/Packet.cs @@ -0,0 +1,39 @@ +using System.Reflection; + +namespace TcpDotNet.Protocol; + +/// +/// Represents the base class for all packets on the protocol. +/// +public abstract class Packet +{ + /// + /// Initializes a new instance of the class. + /// + protected Packet() + { + var attribute = GetType().GetCustomAttribute(); + if (attribute == null) + throw new InvalidOperationException($"The packet is not decorated with {typeof(PacketAttribute)}."); + + Id = attribute.Id; + } + + /// + /// Gets the ID of the packet. + /// + /// The packet ID. + public int Id { get; } + + /// + /// Deserializes this packet from the specified reader. + /// + /// The reader from which this packet should be deserialized. + protected internal abstract Task DeserializeAsync(ProtocolReader reader); + + /// + /// Serializes this packet to the specified writer. + /// + /// The writer to which this packet should be serialized. + protected internal abstract Task SerializeAsync(ProtocolWriter writer); +} diff --git a/TcpDotNet/Protocol/PacketAttribute.cs b/TcpDotNet/Protocol/PacketAttribute.cs new file mode 100644 index 0000000..8bda278 --- /dev/null +++ b/TcpDotNet/Protocol/PacketAttribute.cs @@ -0,0 +1,23 @@ +namespace TcpDotNet.Protocol; + +/// +/// Specifies metadata for a . +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class PacketAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the packet. + public PacketAttribute(int id) + { + Id = id; + } + + /// + /// Gets the ID of the packet. + /// + /// The packet ID. + public int Id { get; } +} diff --git a/TcpDotNet/Protocol/PacketHandler.cs b/TcpDotNet/Protocol/PacketHandler.cs new file mode 100644 index 0000000..e053517 --- /dev/null +++ b/TcpDotNet/Protocol/PacketHandler.cs @@ -0,0 +1,55 @@ +namespace TcpDotNet.Protocol; + +/// +/// Represents the base class for a packet handler. +/// +/// This class should be inherited directly, instead +public abstract class PacketHandler +{ + /// + /// Handles the specified . + /// + /// The recipient of the packet. + /// The packet to handle. + public abstract Task HandleAsync(BaseClientNode recipient, Packet packet); +} + +/// +/// Represents the base class for a packet handler. +/// +public abstract class PacketHandler : PacketHandler + where T : Packet +{ + /// + /// An empty packet handler. + /// + public static readonly PacketHandler Empty = new NullPacketHandler(); + + /// + public override Task HandleAsync(BaseClientNode recipient, Packet packet) + { + if (packet is T actual) return HandleAsync(recipient, actual); + return Task.CompletedTask; + } + + /// + /// Handles the specified . + /// + /// The recipient of the packet. + /// The packet to handle. + public abstract Task HandleAsync(BaseClientNode recipient, T packet); +} + +/// +/// Represents a packet handler that does not handle the packet in any meaningful way. +/// +/// The type of the packet. +internal sealed class NullPacketHandler : PacketHandler + where T : Packet +{ + /// + public override Task HandleAsync(BaseClientNode recipient, T packet) + { + return Task.CompletedTask; + } +} diff --git a/TcpDotNet/Protocol/Packets/ClientBound/PongPacket.cs b/TcpDotNet/Protocol/Packets/ClientBound/PongPacket.cs new file mode 100644 index 0000000..074ae14 --- /dev/null +++ b/TcpDotNet/Protocol/Packets/ClientBound/PongPacket.cs @@ -0,0 +1,40 @@ +namespace TcpDotNet.Protocol.Packets.ClientBound; + +[Packet(0xF1)] +public sealed class PongPacket : Packet +{ + /// + /// Initializes a new instance of the class. + /// + public PongPacket(byte[] payload) + { + Payload = payload[..]; + } + + internal PongPacket() + { + Payload = Array.Empty(); + } + + /// + /// Gets the payload in this packet. + /// + /// The payload. + public byte[] Payload { get; private set; } + + /// + protected internal override Task DeserializeAsync(ProtocolReader reader) + { + int length = reader.ReadInt32(); + Payload = reader.ReadBytes(length); + return Task.CompletedTask; + } + + /// + protected internal override Task SerializeAsync(ProtocolWriter writer) + { + writer.Write(Payload.Length); + writer.Write(Payload); + return Task.CompletedTask; + } +} diff --git a/TcpDotNet/Protocol/Packets/ServerBound/HandshakeRequestPacket.cs b/TcpDotNet/Protocol/Packets/ServerBound/HandshakeRequestPacket.cs new file mode 100644 index 0000000..1297cba --- /dev/null +++ b/TcpDotNet/Protocol/Packets/ServerBound/HandshakeRequestPacket.cs @@ -0,0 +1,41 @@ +namespace TcpDotNet.Protocol.Packets.ServerBound; + +/// +/// Represents a packet which requests a handshake with a . +/// +[Packet(0x00000001)] +internal sealed class HandshakeRequestPacket : Packet +{ + /// + /// Initializes a new instance of the class. + /// + /// The protocol version. + public HandshakeRequestPacket(int protocolVersion) + { + ProtocolVersion = protocolVersion; + } + + internal HandshakeRequestPacket() + { + } + + /// + /// Gets the protocol version in this request. + /// + /// The protocol version. + public int ProtocolVersion { get; private set; } + + /// + protected internal override Task DeserializeAsync(ProtocolReader reader) + { + ProtocolVersion = reader.ReadInt32(); + return Task.CompletedTask; + } + + /// + protected internal override Task SerializeAsync(ProtocolWriter writer) + { + writer.Write(ProtocolVersion); + return Task.CompletedTask; + } +} diff --git a/TcpDotNet/Protocol/Packets/ServerBound/PingPacket.cs b/TcpDotNet/Protocol/Packets/ServerBound/PingPacket.cs new file mode 100644 index 0000000..c6d5159 --- /dev/null +++ b/TcpDotNet/Protocol/Packets/ServerBound/PingPacket.cs @@ -0,0 +1,39 @@ +using System.Security.Cryptography; + +namespace TcpDotNet.Protocol.Packets.ServerBound; + +[Packet(0xF0)] +public sealed class PingPacket : Packet +{ + /// + /// Initializes a new instance of the class. + /// + public PingPacket() + { + using var rng = new RNGCryptoServiceProvider(); + Payload = new byte[4]; + rng.GetBytes(Payload); + } + + /// + /// Gets the payload in this packet. + /// + /// The payload. + public byte[] Payload { get; private set; } + + /// + protected internal override Task DeserializeAsync(ProtocolReader reader) + { + int length = reader.ReadInt32(); + Payload = reader.ReadBytes(length); + return Task.CompletedTask; + } + + /// + protected internal override Task SerializeAsync(ProtocolWriter writer) + { + writer.Write(Payload.Length); + writer.Write(Payload); + return Task.CompletedTask; + } +} diff --git a/TcpDotNet/Protocol/ProtocolReader.cs b/TcpDotNet/Protocol/ProtocolReader.cs new file mode 100644 index 0000000..87a28ae --- /dev/null +++ b/TcpDotNet/Protocol/ProtocolReader.cs @@ -0,0 +1,215 @@ +using System.Net; +using System.Numerics; +using System.Text; + +namespace TcpDotNet.Protocol; + +/// +/// Represents a class that can read protocol-compliant messages from a stream. +/// +public sealed class ProtocolReader : BinaryReader +{ + /// + /// Initializes a new instance of the class. + /// + /// The input stream. + /// + /// The stream does not support reading, is , or is already closed. + /// + public ProtocolReader(Stream input) : base(input, Encoding.UTF8, false) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The input stream. + /// + /// to leave the stream open after the object is disposed; otherwise, + /// . + /// + /// + /// The stream does not support reading, is , or is already closed. + /// + public ProtocolReader(Stream input, bool leaveOpen) : base(input, Encoding.UTF8, leaveOpen) + { + } + + /// + public override short ReadInt16() + { + return IPAddress.NetworkToHostOrder(base.ReadInt16()); + } + + /// + public override int ReadInt32() + { + return IPAddress.NetworkToHostOrder(base.ReadInt32()); + } + + /// + public override long ReadInt64() + { + return IPAddress.NetworkToHostOrder(base.ReadInt64()); + } + + /// + [CLSCompliant(false)] + public override ushort ReadUInt16() + { + return (ushort) IPAddress.NetworkToHostOrder((short) base.ReadUInt16()); + } + + /// + [CLSCompliant(false)] + public override uint ReadUInt32() + { + return (uint) IPAddress.NetworkToHostOrder((int) base.ReadUInt32()); + } + + /// + [CLSCompliant(false)] + public override ulong ReadUInt64() + { + return (ulong) IPAddress.NetworkToHostOrder((long) base.ReadUInt64()); + } + + /// + /// Reads a value from the current stream and advances the current position of the stream by + /// sixteen bytes. + /// + /// A value read from the current stream. + public Quaternion ReadQuaternion() + { + float x = ReadSingle(); + float y = ReadSingle(); + float z = ReadSingle(); + float w = ReadSingle(); + return new Quaternion(x, y, z, w); + } + + /// + /// Reads in a 32-bit integer in compressed format. + /// + /// A 32-bit integer in compressed format. + /// The stream is corrupted. + public int Read7BitEncodedInt32() + { + // Unlike writing, we can't delegate to the 64-bit read on + // 64-bit platforms. The reason for this is that we want to + // stop consuming bytes if we encounter an integer overflow. + + uint result = 0; + byte byteReadJustNow; + + // Read the integer 7 bits at a time. The high bit + // of the byte when on means to continue reading more bytes. + // + // There are two failure cases: we've read more than 5 bytes, + // or the fifth byte is about to cause integer overflow. + // This means that we can read the first 4 bytes without + // worrying about integer overflow. + + const int maxBytesWithoutOverflow = 4; + for (var shift = 0; shift < maxBytesWithoutOverflow * 7; shift += 7) + { + // ReadByte handles end of stream cases for us. + byteReadJustNow = ReadByte(); + result |= (byteReadJustNow & 0x7Fu) << shift; + + if (byteReadJustNow <= 0x7Fu) + return (int) result; // early exit + } + + // Read the 5th byte. Since we already read 28 bits, + // the value of this byte must fit within 4 bits (32 - 28), + // and it must not have the high bit set. + + byteReadJustNow = ReadByte(); + if (byteReadJustNow > 0b_1111u) + throw new FormatException(); + + result |= (uint) byteReadJustNow << (maxBytesWithoutOverflow * 7); + return (int) result; + } + + /// + /// Reads in a 64-bit integer in compressed format. + /// + /// A 64-bit integer in compressed format. + /// The stream is corrupted. + public long Read7BitEncodedInt64() + { + ulong result = 0; + byte byteReadJustNow; + + // Read the integer 7 bits at a time. The high bit + // of the byte when on means to continue reading more bytes. + // + // There are two failure cases: we've read more than 10 bytes, + // or the tenth byte is about to cause integer overflow. + // This means that we can read the first 9 bytes without + // worrying about integer overflow. + + const int maxBytesWithoutOverflow = 9; + for (var shift = 0; shift < maxBytesWithoutOverflow * 7; shift += 7) + { + // ReadByte handles end of stream cases for us. + byteReadJustNow = ReadByte(); + result |= (byteReadJustNow & 0x7Ful) << shift; + + if (byteReadJustNow <= 0x7Fu) + return (long) result; // early exit + } + + // Read the 10th byte. Since we already read 63 bits, + // the value of this byte must fit within 1 bit (64 - 63), + // and it must not have the high bit set. + + byteReadJustNow = ReadByte(); + if (byteReadJustNow > 0b_1u) + throw new FormatException(); + + result |= (ulong) byteReadJustNow << (maxBytesWithoutOverflow * 7); + return (long) result; + } + + /// + /// Reads a value from the current stream and advances the current position of the stream by eight + /// bytes. + /// + /// A value read from the current stream. + public Vector2 ReadVector2() + { + float x = ReadSingle(); + float y = ReadSingle(); + return new Vector2(x, y); + } + + /// + /// Reads a value from the current stream and advances the current position of the stream by + /// twelve bytes. + /// + /// A value read from the current stream. + public Vector3 ReadVector3() + { + float x = ReadSingle(); + float y = ReadSingle(); + float z = ReadSingle(); + return new Vector3(x, y, z); + } + + /// + /// Reads a value from the current stream and advances the current position of the stream by + /// sixteen bytes. + /// + /// A value read from the current stream. + public Vector4 ReadVector4() + { + float x = ReadSingle(); + float y = ReadSingle(); + float z = ReadSingle(); + float w = ReadSingle(); + return new Vector4(x, y, z, w); + } +} diff --git a/TcpDotNet/Protocol/ProtocolWriter.cs b/TcpDotNet/Protocol/ProtocolWriter.cs new file mode 100644 index 0000000..77b5230 --- /dev/null +++ b/TcpDotNet/Protocol/ProtocolWriter.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Numerics; +using System.Text; + +namespace TcpDotNet.Protocol; + +/// +/// Represents a class that can write protocol-compliant messages to a stream. +/// +public sealed class ProtocolWriter : BinaryWriter +{ + /// + /// Initializes a new instance of the class. + /// + /// The input stream. + /// The stream does not support writing or is already closed. + /// is . + public ProtocolWriter(Stream output) : base(output, Encoding.UTF8, false) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The input stream. + /// + /// to leave the stream open after the object is disposed; otherwise, + /// . + /// + /// The stream does not support writing or is already closed. + /// is . + public ProtocolWriter(Stream output, bool leaveOpen) : base(output, Encoding.UTF8, leaveOpen) + { + } + + /// + public override void Write(short value) + { + base.Write(IPAddress.HostToNetworkOrder(value)); + } + + /// + public override void Write(int value) + { + base.Write(IPAddress.HostToNetworkOrder(value)); + } + + /// + public override void Write(long value) + { + base.Write(IPAddress.HostToNetworkOrder(value)); + } + + /// + [CLSCompliant(false)] + public override void Write(ushort value) + { + base.Write((ushort)IPAddress.HostToNetworkOrder((short)value)); + } + + /// + [CLSCompliant(false)] + public override void Write(uint value) + { + base.Write((uint) IPAddress.HostToNetworkOrder((int) value)); + } + + /// + [CLSCompliant(false)] + public override void Write(ulong value) + { + base.Write((ulong) IPAddress.HostToNetworkOrder((long) value)); + } + + /// + /// Writes a value to the current stream and advances the stream position by sixteen bytes. + /// + /// The value to write. + public void Write(Quaternion value) + { + Write(value.X); + Write(value.Y); + Write(value.Z); + Write(value.W); + } + + /// + /// Writes a value to the current stream and advances the stream position by eight bytes. + /// + /// The value to write. + public void Write(Vector2 value) + { + Write(value.X); + Write(value.Y); + } + + /// + /// Writes a value to the current stream and advances the stream position by twelve bytes. + /// + /// The value to write. + public void Write(Vector3 value) + { + Write(value.X); + Write(value.Y); + Write(value.Z); + } + + /// + /// Writes a value to the current stream and advances the stream position by sixteen bytes. + /// + /// The value to write. + public void Write(Vector4 value) + { + Write(value.X); + Write(value.Y); + Write(value.Z); + Write(value.W); + } + + /// + /// Writes a 32-bit integer in a compressed format. + /// + /// The 32-bit integer to be written. + public void Write7BitEncodedInt32(int value) + { + var uValue = (uint) value; + + // Write out an int 7 bits at a time. The high bit of the byte, + // when on, tells reader to continue reading more bytes. + // + // Using the constants 0x7F and ~0x7F below offers smaller + // codegen than using the constant 0x80. + + while (uValue > 0x7Fu) + { + Write((byte) (uValue | ~0x7Fu)); + uValue >>= 7; + } + + Write((byte) uValue); + } + + /// + /// Writes a 64-bit integer in a compressed format. + /// + /// The 64-bit integer to be written. + public void Write7BitEncodedInt64(long value) + { + var uValue = (ulong) value; + + // Write out an int 7 bits at a time. The high bit of the byte, + // when on, tells reader to continue reading more bytes. + // + // Using the constants 0x7F and ~0x7F below offers smaller + // codegen than using the constant 0x80. + + while (uValue > 0x7Fu) + { + Write((byte) ((uint) uValue | ~0x7Fu)); + uValue >>= 7; + } + + Write((byte) uValue); + } +} diff --git a/TcpDotNet/ProtocolClient.cs b/TcpDotNet/ProtocolClient.cs new file mode 100644 index 0000000..2c875c2 --- /dev/null +++ b/TcpDotNet/ProtocolClient.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; + +namespace TcpDotNet; + +/// +/// Represents a client on the TcpDotNet protocol. +/// +public sealed class ProtocolClient : BaseClientNode +{ + /// + /// Initializes a new instance of the class. + /// + public ProtocolClient() + { + Aes.GenerateKey(); + Aes.GenerateIV(); + } + + /// + /// Establishes a connection to a remote host. + /// + /// The remote host to which this client should connect. + /// The remote port to which this client should connect. + /// is . + /// contains an empty string. + /// + /// is less than . -or - is greater + /// than . + /// + /// An error occurred when attempting to access the socket. + public Task ConnectAsync(string host, int port) + { + return ConnectAsync(new DnsEndPoint(host, port)); + } + + /// + /// Establishes a connection to a remote host. + /// + /// The remote to which this client should connect. + /// The remote port to which this client should connect. + /// + /// is less than . -or - is greater + /// than . -or- is less than 0 or greater than + /// 0x00000000FFFFFFFF. + /// + /// An error occurred when attempting to access the socket. + public Task ConnectAsync(IPAddress address, int port) + { + return ConnectAsync(new IPEndPoint(address, port)); + } + + /// + /// Establishes a connection to a remote host. + /// + /// An EndPoint that represents the remote device. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// is . + /// An error occurred when attempting to access the socket. + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + if (remoteEP is null) throw new ArgumentNullException(nameof(remoteEP)); + BaseSocket = new Socket(remoteEP.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await Task.Run(() => BaseSocket.ConnectAsync(remoteEP), cancellationToken); + IsConnected = true; + } +} diff --git a/TcpDotNet/ProtocolListener.Client.cs b/TcpDotNet/ProtocolListener.Client.cs new file mode 100644 index 0000000..f8d09ea --- /dev/null +++ b/TcpDotNet/ProtocolListener.Client.cs @@ -0,0 +1,63 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using TcpDotNet.Protocol; + +namespace TcpDotNet; + +public sealed partial class ProtocolListener +{ + /// + /// Represents a client that is connected to a . + /// + public sealed class Client : BaseClientNode + { + internal Client(ProtocolListener listener, Socket socket) + { + ParentListener = listener ?? throw new ArgumentNullException(nameof(listener)); + BaseSocket = socket ?? throw new ArgumentNullException(nameof(socket)); + SessionId = Guid.NewGuid(); + IsConnected = true; + } + + /// + /// Gets the listener to which this client is connected. + /// + /// The parent listener. + public ProtocolListener ParentListener { get; } + + internal void Start() + { + foreach (Type packetType in ParentListener.RegisteredPackets.Values) + RegisterPacket(packetType); + + foreach ((Type packetType, PacketHandler handler) in ParentListener.RegisteredPacketHandlers) + RegisterPacketHandler(packetType, handler); + + Task.Run(ReadLoopAsync); + } + + private async Task ReadLoopAsync() + { + while (IsConnected) + { + try + { + Packet? packet = await ReadNextPacketAsync(); + if (packet is not null) ParentListener.OnClientSendPacket(this, packet); + } + catch (DisconnectedException) + { + ParentListener.OnClientDisconnect(this, DisconnectReason.Disconnect); + IsConnected = false; + } + catch (EndOfStreamException) + { + ParentListener.OnClientDisconnect(this, DisconnectReason.EndOfStream); + IsConnected = false; + } + } + } + } +} diff --git a/TcpDotNet/ProtocolListener.cs b/TcpDotNet/ProtocolListener.cs new file mode 100644 index 0000000..b922865 --- /dev/null +++ b/TcpDotNet/ProtocolListener.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Sockets; +using TcpDotNet.EventData; +using TcpDotNet.Protocol; + +namespace TcpDotNet; + +/// +/// Represents a listener on the TcpDotNet protocol. +/// +public sealed partial class ProtocolListener : Node +{ + private readonly List _clients = new(); + + /// + /// Occurs when a client connects to the listener. + /// + public event EventHandler? ClientConnected; + + /// + /// Occurs when a client disconnects from the listener. + /// + public event EventHandler? ClientDisconnected; + + /// + /// Occurs when a client sends a packet to the listener. + /// + public event EventHandler? ClientPacketReceived; + + /// + /// Occurs when the server has started. + /// + public event EventHandler? Started; + + /// + /// Occurs when the server has started. + /// + public event EventHandler? Stopped; + + /// + /// Gets a read-only view of the clients connected to this listener. + /// + /// A read-only view of the clients connected to this listener. + public IReadOnlyCollection Clients + { + get + { + lock (_clients) + return _clients.AsReadOnly(); + } + } + + /// + /// Gets a value indicating whether the server is running. + /// + /// if the server is running; otherwise, . + public bool IsRunning { get; private set; } + + /// + /// Gets the local endpoint. + /// + /// The that is using for communications. + public EndPoint LocalEndPoint => BaseSocket.LocalEndPoint; + + /// + /// Starts the listener on the specified port, using as the bind address, or + /// if is . + /// + /// + /// is less than or greater than + /// . + /// + /// An error occurred when attempting to access the socket. + public void Start(int port) + { + IPAddress bindAddress = Socket.OSSupportsIPv6 ? IPAddress.IPv6Any : IPAddress.Any; + Start(new IPEndPoint(bindAddress, port)); + } + + /// + /// Starts the listener on the specified endpoint. + /// + /// is . + /// An error occurred when attempting to access the socket. + public void Start(EndPoint localEP) + { + if (localEP is null) throw new ArgumentNullException(nameof(localEP)); + BaseSocket = new Socket(localEP.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + if (localEP.AddressFamily == AddressFamily.InterNetworkV6 && Socket.OSSupportsIPv6) + BaseSocket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false); + + BaseSocket.Bind(localEP); + BaseSocket.Listen(10); + IsRunning = true; + + Started?.Invoke(this, EventArgs.Empty); + Task.Run(AcceptLoop); + } + + private void OnClientDisconnect(Client client, DisconnectReason disconnectReason) + { + lock (_clients) _clients.Remove(client); + ClientDisconnected?.Invoke(this, new ClientDisconnectedEventArgs(client, disconnectReason)); + } + + private void OnClientSendPacket(Client client, Packet packet) + { + ClientPacketReceived?.Invoke(this, new ClientPacketReceivedEventArgs(client, packet)); + } + + private async Task AcceptLoop() + { + while (IsRunning) + { + Socket socket = await BaseSocket.AcceptAsync(); + + var client = new Client(this, socket); + lock (_clients) _clients.Add(client); + + client.Start(); + ClientConnected?.Invoke(this, new ClientConnectedEventArgs(client)); + } + } +} diff --git a/TcpDotNet/TcpDotNet.csproj b/TcpDotNet/TcpDotNet.csproj new file mode 100644 index 0000000..bf8bc39 --- /dev/null +++ b/TcpDotNet/TcpDotNet.csproj @@ -0,0 +1,10 @@ + + + + netstandard2.1 + enable + enable + 10 + + +