Click here to Skip to main content
15,868,004 members
Articles / Mobile Apps / Windows Phone 7

Extendable TCP Client/Server Application Framework

Rate me:
Please Sign up or sign in to vote.
4.91/5 (12 votes)
4 May 2019CPOL14 min read 37.6K   736   44   12
Provides an introduction to the open source client/server DotNetOpenServer SDK project for Android, iOS, Windows Phone, Windows, Mac and Java Platforms

Introduction

If you're reading this article, you've probably already seen the countless .NET TCP socket server articles on CodeProject. What I haven't found is an open source solution that is fast and efficient, easily extendable, includes security, specifically TLS 1.2 support, and includes an API for Android, iOS, Windows Phone, Windows, Mac and Java clients.

This article showcases an open source project I created called DotNetOpenServer SDK. The SDK provides a client/server application framework that implements an extendable binary protocol stack, supports SSL/TLS 1.2 connections, includes an extendable security framework, includes a keep-alive/heartbeat protocol and includes a C# API for Windows and Windows Mobile, a Java API for Android and Unix/Linux flavors and an Objective-C API for iOS and Mac.

What This Article Is and Is Not

There are already many articles on CodeProject that show developers how to create both synchronous and asynchronous TCP client/server applications. This article does not attempt to recreate what has already been done so well. Instead, this article provides a high level overview of the framework, details the framework's binary protocol stack, summarizes how to extend the framework with protocol implementations, summarizes how to create your own authentication protocol and provides a detailed tutorial to create your own client/server application.

Links and 3rd Party References

Architecture

DotNetOpenServer is implemented on Windows using the .NET Framework 4.5.2. The server is implemented as an asynchronous TCP socket server. .NET components are used to negotiate SSL/TLS 1.2. Protocols are implemented in stand-alone assemblies which, when requested by the client, are loaded using Reflection. The information required for the server to load the assemblies is contained within the app.config file or can optionally be programmatically set.

Projects

When I built this API, my goal was to minimize duplicate code as much as possible. What I ended up with was several solutions and even more projects. At the lowest level is a Portable project called OpenServerShared that is shared by the server, Windows client and Windows Mobile client. Next lies a Windows project called OpenServerWindowsShared that is shared between the server and Windows client. Finally, there is a project for the server, Windows client and Windows Mobile client.

Project Hierarchy

Since Android applications are written in Java and I wanted to support Unix and Linux flavors, I was able to further minimize code duplication. Using Eclipse, I was able to create a single Java project responsible for generating JAR files that can be used by both Android and Unix/Linux applications.

For those of you that are unfamiliar with programming on Apple operating systems, iOS (iPhone and iPad) and OS X (Mac) applications are written in Objective-C or Swift. When I started this project, I did not know the Objective-C or Swift languages so I researched auto-generation options. I was pleasantly surprised to learn about the J2ObjC project and ever more presently surprised to find all of the code not only ported without issue but appeared to executed flawlessly. Again, I was able to minimize code duplication. Moving forward, once a good Java to Swift converter becomes available, I plan on including a Swift API as well.

J2ObjC

Fast and Efficient

Since a core requirement of this project was to create a protocol stack that executed quickly and efficiently, I'm going to start out detailing the protocol stack. If you are not interested in the byte streams, go ahead and skip this section.

Session Layer Protocol (SLP)

The Session Layer Protocol (SLP) is the first protocol in the stack. SLP is a very simple protocol that transmits 6 bytes of data. The first field contains a 2 byte identification number. Although the identification number is technically not necessary, I felt it was a good addition for sniffing TCP packets that contain multiple DotNetOpenServer packets. The next field contains a 4 byte Uint32 that specifies the length of the payload in bytes. The last field contains a 2 byte Uint16 that specifies the next layer's unique protocol identifier. This last field is included in the payload length. Also worth noting here, all integer data types are transmitted in little endian (least significant digit first).

For illustration purposes, here's what a packet would look like that is transmitting 3 bytes of data for a protocol that identifies itself as 0x0B0A and has a payload of one byte with the value of 0xFF:

53 55 03 00 00 00 0A 0B FF

Capabilities Protocol (CAP)

DotNetOpenServer includes an internal Capabilities Protocol (CAP) that enables client applications to query the server for a list of supported protocols. Also worth noting, if a client attempts to initialize a protocol that is not supported, the server returns the error through CAP. CAP's unique protocol identifier is 0x0000. The first byte after the protocol identifier contains the command value.

Command Value Type Description
GET_PROTOCOL_IDS 0X01 Request Sent by clients to get a list of server supported protocol IDs
PROTOCOL_IDS 0X02 Response Sent by the server in response to a GET_PROTOCOL_ID command
ERROR 0xFF Response Sent by the server when the client requests a protocol that is not supported by the server

Here's what a GET_PROTOCOL_IDS command looks like:

53 55 03 00 00 00 00 00 01

Here's what a sample response looks like:

53 55 0D 00 00 00 00 00 02 03 00 00 00 01 00 02 00 0A 00

The response payload contains a Uint32 that contains the number of supported protocols followed by each protocol's identifier. In this case, 3 protocols are supported: 1, 2, and 10. In Hex, that's 0x0001, 0x0002 and 0x000A.

Keep-Alive Protocol (KAP)

DotNetOpenServer includes a Keep-Alive Protocol (KAP) which can optionally be enabled or disabled. The purpose of this protocol is three-fold. First, the server was implemented to drop inactive connections. KAP keeps the connection alive by sending very small packets back and forth. Secondly, since KAP is sending and receiving packets, it is able to identify idle or broken connections thus enabling both ends to close broken connections, free resources and notify the client application when a network failure occurs. Lastly, KAP includes a QUIT command enabling both ends to notify the other prior to terminating the session enabling resources to be freed in a timely fashion. If enabled, KAP is automatic and does not require nor offer any external methods. KAP's unique protocol identifier is 0x0001. The first byte after the protocol identifier contains the command value.

Command Value Description
KEEP_ALIVE 0X01 Sent by both the client and server to keep an idle session open.
QUIT 0xFF Sent by both the client and server to provide notification to the end point the session is closing.

Here's what a KEEP-ALIVE command looks like:

53 55 03 00 00 00 01 00 01

Extendable

I designed DotNetOpenServer so protocols can easily be added by developers at any time in the future without the need to recompile or redeploy the server. This is accomplished using Reflection. In other words, developers can create and install their protocols to pre-existing deployments of the server. Protocols are implemented by simply creating a .NET Assembly or JAR file that contains a class that is derived from the ProtocolBase class. Typically, you create a class for the server-side and another class for the client-side. Once implemented, you can either configure the server to load the protocol using the app.config file or you can programmatically configure the server. Finally, update your client applications to use your protocol.

The SDK contains tutorials that step you through the process of creating a server-side side protocol as well as client-side protocols for each of the supported platforms.

Security

A client/server framework wouldn't be complete without security. Included in the SDK is a Windows Authentication Protocol, however; you can create and add as many authentication protocols as required. For example, the server can host a protocol that authenticates users through Facebook as well as a protocol that authenticates users through a proprietary corporate database. CAP, as mentioned above, enables client-side users the option to choose the authentication protocol.

Authentication Protocols are implemented by simply creating a .NET Assembly or JAR file that contains a class that is derived from the AuthenticationProtocolBase class. Create a class for the server-side, then implement your authentication method. Next, create a .NET and/or Java class that remotely calls your authentication method. If your authentication methods support roles and/or groups, and you want to check role membership from other protocols, override the IsInRole method within your server-side implementation. If you plan on creating your own authentication method, as a starting point, I suggest reviewing the Windows Authentication Protocol or Database Authentication Protocol source code located in the attached source as well as on GitHub.

Any protocols you create can build on top of the security model by including their own authorization functions. Protocol implementations can access the calling client's username through the ProtocolBase.Session.AuthenticationProtocol property. The returned object, an instance of AuthenticationProtocolBase, offers a UserName property and, as mentioned above, an IsInRole(string role) method.

Putting It All Together

Creating a Server Application

Creating a sample server application is very simple.

First, create a .NET 4.5.2 Console Application.

Next, add the DotNetOpenServer assemblies. To add the assemblies, open the NuGet Package Manager Console, then type the following commands:

PowerShell
PM> Install-Package UpperSetting.OpenServer
PM> Install-Package UpperSetting.OpenServer.Protocols.KeepAlive
PM> Install-Package UpperSetting.OpenServer.Protocols.WinAuth.Server
PM> Install-Package UpperSetting.OpenServer.Protocols.Hello.Server
PM> Install-Package log4net
Package Description
UpperSetting.OpenServer Contains the server
UpperSetting.OpenServer.Protocols.KeepAlive Contains both the server and client Keep-Alive protocol implementations
UpperSetting.OpenServer.Protocols.WinAuth.Server Contains the server-side Windows Authentication protocol implementation
UpperSetting.OpenServer.Protocols.Hello.Server Contains a sample protocol that simply echos a hello message back to the client
log4net Contains the Apache log4net assemblies enabling you to log messages using log4net

As previously mentioned, the server can be configured using either the app.conifg file or programmatically. Both methods are fairly simple.

To Create an Instance of the Server Using the app.config

First, add the code to create the server. When using the app.config to configure the server, the application code required to start the server is very simple. Simply create an instance of US.OpenServer.Server. When your application is ready to shutdown, call the Server.Close method. For example:

C#
using System;
using US.OpenServer;

namespace HelloServer
{
    class Program
    {
        static void Main(string[] args)
        {
            Server server = new Server();
            server.Logger.Log(Level.Info, "Press any key to quit.");
            Console.ReadKey();
            server.Close();
        }
    }
}

Next, add the configuration to the app.config file. 3 sections are required: log4net, server and protocols. Once added to the 'configSections' element, each section must be implemented.

The 'log4net' Section

The log4net section is extensive and outside the scope of this article. For more information, please see the Apache log4net website.

The 'server' Section

The 'server' section contains the following elements:

Element/Attribute Description
host The IP address to bind the TCP socket server. Defaults to 0.0.0.0 (all IP addresses).
port The TCP port to run the server. Defaults to 21843.
tls True to enable SSL/TLS 1.2, otherwise False. Defaults to False.
tls/certificate The X509Certificate used to authenticate.
tls/requireRemoteCertificate True to require the end point to supply a certificate for authentication, otherwise False.
tls/allowSelfSignedCertificate True to enable self-signed certificates, otherwise False.
tls/checkCertificateRevocation True to check the certificate revocation list during authentication, otherwise False.
tls/allowCertificateChainErrors True to check the certificate chain during authentication, otherwise False.
idleTimeout The number of seconds a connection can remain idle before the connection is automatically closed. Defaults to 300 seconds.
receiveTimeout The number of seconds a receive operation blocks waiting for data. Defaults to 120 seconds.
sendTimeout The number of seconds a send operation blocks waiting for data. Defaults to 120 seconds.

For example:

XML
<server>    
    <host value="0.0.0.0" />
    <port value="21843" />
    <tls value="true"
         certificate="UpperSetting.com"
         requireRemoteCertificate="false"
         allowSelfSignedCertificate="false"
         checkCertificateRevocation="true"
         allowCertificateChainErrors="false"/>
    <idleTimeout value="300" />
    <receiveTimeout value="120" />
    <sendTimeout value="120" />
</server>

The 'protocols' Section

The 'protocols' section contains an array of 'item' elements. Each 'item' element contains 4 attributes which I've defined in the table below:

Attribute Description
id A Uint16 that specifies the unique protocol identifier.
assembly A String that specifies the assembly the protocol is implemented.
classPath A String that specifies the class path of the class the protocol is implemented.
configClassPath A String that specifies the class path of the class the protocol configuration reader is implemented.

For example:

XML
<protocols>
    <item id="1"
          assembly="US.OpenServer.Protocols.KeepAlive.dll"
          classPath="US.OpenServer.Protocols.KeepAlive.KeepAliveProtocol" />
    <item id="2"
          assembly="US.OpenServer.Protocols.WinAuth.Server.dll"
          classPath="US.OpenServer.Protocols.WinAuth.WinAuthProtocolServer"
          configClassPath="US.OpenServer.Protocols.WinAuth.WinAuthProtocolConfigurationServer">
        <permissions>
            <roles>
                <role value="Administrators" />
            </roles>
            <users>
                <user value="TestUser" />                                    
            </users>
        </permissions>
    </item>
    <item id="10"
          assembly="US.OpenServer.Protocols.Hello.Server.dll"
          classPath="US.OpenServer.Protocols.Hello.HelloProtocolServer" />
</protocols>

A complete app.config sample can be found in the SDK documentation.

Finally, compile and run.

To Create an Instance of the Server Programmatically

First, add the code to create the server. From the Main function, create a US.OpenServer.Configuration.ServerConfiguration object, then set any properties you want to override including SSL/TLS properties.

C#
ServerConfiguration cfg = new ServerConfiguration();
cfg.TlsConfiguration.Enabled = true;
cfg.TlsConfiguration.Certificate = "UpperSetting.com";

Next, create a US.OpenServer.Protocols.WinAuth.WinAuthProtocolConfigurationServer object, then add the groups and users you want to authorize access.

C#
WinAuthProtocolConfigurationServer winAuthCfg = new WinAuthProtocolConfigurationServer();
winAuthCfg.AddRole("Administrators");
winAuthCfg.AddUser("TestUser");

Next, create a Dictionary of US.OpenServer.Protocols.ProtocolConfiguration objects keyed by the unique protocol identifier that contains the following three protocols:

  • US.OpenServer.Protocols.WinAuth.WinAuthProtocolServer
  • US.OpenServer.Protocols.KeepAlive.KeepAliveProtocol
  • US.OpenServer.Protocols.Hello.HelloProtocol
C#
Dictionary<ushort, ProtocolConfiguration> protocolConfigurations =
    new Dictionary<ushort, ProtocolConfiguration>();

protocolConfigurations.Add(WinAuthProtocol.PROTOCOL_IDENTIFIER, winAuthCfg);

protocolConfigurations.Add(KeepAliveProtocol.PROTOCOL_IDENTIFIER,
    new ProtocolConfiguration
      (KeepAliveProtocol.PROTOCOL_IDENTIFIER, typeof(KeepAliveProtocol)));

protocolConfigurations.Add(HelloProtocol.PROTOCOL_IDENTIFIER,
    new ProtocolConfiguration
      (HelloProtocol.PROTOCOL_IDENTIFIER, typeof(HelloProtocolServer)));

Next, create the US.OpenServer.Server passing in the ServerConfiguration and the Dictionary of ProtocolConfiguration objects.

C#
Server server = new Server(cfg, protocolConfigurations);

Finally, when your application is ready to shutdown, call Server.Close().

C#
server.Close();

The complete source code for this server sample application can be found in the attached source as well as on GitHub.

Creating Client Applications

As I mentioned in the introduction, I wanted to include an API for Android, iOS, Windows Phone, Windows, Mac and Java clients. In an effort to keep this article as short as possible, I will only show you how to create a Windows client, however; the DotNetOpenServer SDK includes tutorials for each supported platform.

To Create a Windows Client-Side

Creating a sample client application is very simple.

Step 1

Create a .NET 4.5.2 Console Application.

Step 2

Add the DotNetOpenServer assemblies. To add the assemblies, open the NuGet Package Manager Console, then type the following commands:

PowerShell
PM> Install-Package UpperSetting.OpenServer.Windows.Client
PM> Install-Package UpperSetting.OpenServer.Protocols.KeepAlive
PM> Install-Package UpperSetting.OpenServer.Protocols.WinAuth.Client
PM> Install-Package UpperSetting.OpenServer.Protocols.Hello.Client
Package Description
UpperSetting.OpenServer.Windows.Client Contains the client
UpperSetting.OpenServer.Protocols.KeepAlive Contains both the server and client Keep-Alive protocol implementations
UpperSetting.OpenServer.Protocols.WinAuth.Client Contains the client-side Windows Authentication protocol implementation
UpperSetting.OpenServer.Protocols.Hello.Client Contains a sample protocol that simply sends a message to the server and receives an echo response

Step 3

From the Main function, create a US.OpenServer.Configuration.ServerConfiguration object, then set any properties you want to override including SSL/TLS properties. If you copy the code below, replace the ServerConfiguration.Host value with the hostname your server is running.

C#
ServerConfiguration cfg = new ServerConfiguration();
cfg.Host = "UpperSetting.com";
cfg.TlsConfiguration.Enabled = true;

Step 4

Create a Dictionary of US.OpenServer.Protocols.ProtocolConfiguration objects keyed by the unique protocol identifier that contains the following three protocols:

  • US.OpenServer.Protocols.WinAuth.WinAuthProtocolClient
  • US.OpenServer.Protocols.KeepAlive.KeepAliveProtocol
  • US.OpenServer.Protocols.Hello.HelloProtocolClient
C#
Dictionary<ushort, ProtocolConfiguration> protocolConfigurations =
    new Dictionary<ushort, ProtocolConfiguration>();

protocolConfigurations.Add(WinAuthProtocol.PROTOCOL_IDENTIFIER,
    new ProtocolConfiguration
     (WinAuthProtocol.PROTOCOL_IDENTIFIER, typeof(WinAuthProtocolClient)));
    
protocolConfigurations.Add(KeepAliveProtocol.PROTOCOL_IDENTIFIER,
    new ProtocolConfiguration
     (KeepAliveProtocol.PROTOCOL_IDENTIFIER, typeof(KeepAliveProtocol)));

protocolConfigurations.Add(HelloProtocol.PROTOCOL_IDENTIFIER,
    new ProtocolConfiguration
     (HelloProtocol.PROTOCOL_IDENTIFIER, typeof(HelloProtocolClient)));

Step 5

Create the US.OpenServer.Client passing in the ServerConfiguration and the Dictionary of ProtocolConfiguration objects.

C#
client = new Client(cfg, protocolConfigurations);

Step 6

Call Client.Connect to connect to server.

C#
client.Connect();

Step 7

To get a list of protocols running on the server, call Client.GetServerSupportedProtocolIds. For example:

C#
ushort[] protocolIds = client.GetServerSupportedProtocolIds();
foreach (int protocolId in protocolIds)
    client.Logger.Log(Level.Info, "Server Supports Protocol ID: " + protocolId);

Step 8

Initialize the WinAuthProtocolClient protocol, then call WinAuthProtocolClient.Authenticate to authenticate the connection. If you copy the code below, replace the username/password below with your username/password.

C#
string userName = "TestUser";
string password = "T3stus3r";
string domain = null;
WinAuthProtocolClient wap = 
   client.Initialize(WinAuthProtocol.PROTOCOL_IDENTIFIER) as WinAuthProtocolClient;
if (!wap.Authenticate(userName, password, domain))
    throw new Exception("Access denied.");

Step 9

Initialize the KeepAliveProtocol to enable the client/server Keep-Alive (aka Heartbeat) protocol.

client.Initialize(KeepAliveProtocol.PROTOCOL_IDENTIFIER);

Step 10

Initialize the HelloProtocolClient, then call HelloProtocolClient.Hello. For example:

C#
HelloProtocolClient hpc = (HelloProtocolClient)client.Initialize
                               (HelloProtocol.PROTOCOL_IDENTIFIER);
string serverReponse = hpc.Hello(userName);
client.Logger.Log(Level.Info, serverReponse);

Each of the Client constructor parameters can be set to null. If a parameter is set to null, the constructor will create an instance of the configuration object using the default property values, then attempt to load the properties from the app.config file.

Step 11

Compile and run. The client/server applications should display the following output.

Server Output
Info Execution Mode: Debug
Info Press any key to quit.
Info Listening on 0.0.0.0:21843...
Info Session [1 127.0.0.1] - Connected.
Debug Session [1 127.0.0.1] - [Capabilities] Sent Protocol IDs: 1, 2, 10
Debug Session [1 127.0.0.1] - Initializing protocol 2...
Info Session [1 127.0.0.1] - [WinAuth] Authenticated \TestUser.
Debug Session [1 127.0.0.1] - Initializing protocol 10...
Info Session [1 127.0.0.1] - [Hello] Client says: TestUser
Info Session [1 127.0.0.1] - [Hello] Server responded: Hello TestUser
Debug Session [1 127.0.0.1] - Initializing protocol 1...
Debug Session [1 127.0.0.1] - [Keep-Alive] Received.
Debug Session [1 127.0.0.1] - [Keep-Alive] Received.
Debug Session [1 127.0.0.1] - [Keep-Alive] Sent.
Info Session [1 127.0.0.1] - [Keep-Alive] Quit received.
Info Session [1 127.0.0.1] - Disposed.
Client Output
Info Execution Mode: Debug
Info Connecting to localhost:21843...
Info Connected to localhost:21843.
Debug Session [1 127.0.0.1] - [Capabilities] Received Protocol IDs: 1, 2, 10
Info Server Supports Protocol ID: 1
Info Server Supports Protocol ID: 2
Info Server Supports Protocol ID: 10
Debug Session [1 127.0.0.1] - Initializing protocol 2...
Info Session [1 127.0.0.1] - [WinAuth] Authenticated.
Debug Session [1 127.0.0.1] - Initializing protocol 1...
Debug Session [1 127.0.0.1] - Initializing protocol 10...
Info Session [1 127.0.0.1] - [Hello] Client says: TestUser
Info Session [1 127.0.0.1] - [Hello] Server responded: Hello TestUser
Info Hello TestUser
Info Press any key to quit.
Debug Session [1 127.0.0.1] - [Keep-Alive] Sent.
Debug Session [1 127.0.0.1] - [Keep-Alive] Sent.
Debug Session [1 127.0.0.1] - [Keep-Alive] Received.
Debug Session [1 127.0.0.1] - [Keep-Alive] Quit sent.
Info Session [1 127.0.0.1] - Closed.

The complete source code for this sample application can be found in the attached source as well as on GitHub.

Conclusion

I've shown you how you can use the open source DotNetOpenServer SDK to create smart mobile device applications that securely access data and/or business logic running in the cloud. I've provided several links that show you how to create your own protocols including how to implement your own authentication model. I've detailed how to create a Windows client/server application using the SDK and provided links to create Android, iOS, Windows Phone, Mac and Java clients.

I hope this open source project is beneficial to the community and I welcome all of your comments. Thank you very much for reading this article.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
CEO
United States United States
I am a self taught developer that has been developing both backend and frontend Windows applications since the early 1990s. I started out working for Intel as a C/C++ developer in their network management software division located here in Utah. After 3 years I left the large corporate environment to work with a startup company called emWare which developed device management software. While at emWare I worked on low level multi-threaded client/server code written in C/C++ and Java as well as many frontend MFC and later C# applications. In 2006 I started my own software company called Corner Bowl Software which provided enterprise class systems management software. In 2012 I was approached by another software company to purchase Corner Bowl Software; which I sold. I have since started a new company called Upper Setting that provides several client/server application framework SDKs and software consulting services.

Comments and Discussions

 
GeneralHow Does DNS work? Pin
Member 124307046-May-19 0:33
Member 124307046-May-19 0:33 
PraiseGREAT DISCUSSION Pin
eql business solutions30-Aug-16 23:56
eql business solutions30-Aug-16 23:56 
GeneralRe: GREAT DISCUSSION Pin
Michael Janulaitis3-Apr-18 3:24
Michael Janulaitis3-Apr-18 3:24 
GeneralCodeProject-hosted download Pin
Sascha Lefèvre17-Apr-16 21:45
professionalSascha Lefèvre17-Apr-16 21:45 
PraiseGreat architectural discussion Pin
Member 1241496624-Mar-16 9:32
Member 1241496624-Mar-16 9:32 
GeneralRe: Great architectural discussion Pin
Michael Janulaitis3-Apr-18 3:23
Michael Janulaitis3-Apr-18 3:23 
SuggestionHow about HTML5 background-long calls, web-sockets, and comet-like protocol compatibility Pin
AndyHo18-Mar-16 5:50
professionalAndyHo18-Mar-16 5:50 
GeneralRe: How about HTML5 background-long calls, web-sockets, and comet-like protocol compatibility Pin
Michael Janulaitis18-Mar-16 14:36
Michael Janulaitis18-Mar-16 14:36 
QuestionWhat about a ASP.NET Core 1.0 version? Pin
Jose Motta17-Mar-16 5:18
Jose Motta17-Mar-16 5:18 
Hi Michael, great job! This platform can be useful to IoT projects using Raspberry PI with new Windows 10 and ASP.NET Core 1.0. Do you plan to go this way also?
J.Motta

AnswerRe: What about a ASP.NET Core 1.0 version? Pin
Michael Janulaitis17-Mar-16 7:49
Michael Janulaitis17-Mar-16 7:49 
GeneralRe: What about a ASP.NET Core 1.0 version? Pin
MatteoVi21-Jul-17 10:58
MatteoVi21-Jul-17 10:58 
GeneralRe: What about a ASP.NET Core 1.0 version? Pin
Michael Janulaitis3-Apr-18 3:22
Michael Janulaitis3-Apr-18 3:22 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.