Click here to Skip to main content
15,880,891 members
Articles / Programming Languages / C#
Article

Secure VNC Viewer

Rate me:
Please Sign up or sign in to vote.
4.53/5 (14 votes)
28 Apr 2007CPOL12 min read 200.5K   9.8K   87   35
Helper application to automatically establish SSH or SSL tunnels for VNC connections.

Sample image

Introduction

While VNC is a great, cross-platform remote desktop protocol, it is inherently insecure, and relies on the system administrator that installs it to tunnel it through another secure communication protocol if the VNC server is to be publicly accessible. The two most common ways to do this are to tunnel VNC data through an SSL or an SSH connection. There's a cross-platform GUI application called ssvnc that handles setting up an SSL or SSH tunnel automatically prior to launching the VNC viewer process but, frankly, it has a number of shortcomings that led me to develop my own secure VNC viewer application. First, ssvnc is kind of clunky, and lacks a professional polish; it spawns several other windows to handle the secure connection process, and doesn't provide a unified interface. Secondly, it's not implementation agnostic: it contains inputs for custom parameters for several of the major VNC implementations, but a better approach would be to allow the user to simply specify the viewer application that they wish to run and the command line parameters to use with that application. So, these frustrations led me to develop my own secure VNC viewer application.

Background

You might be wondering, why bother with VNC? RDP (Remote Desktop Protocol, used by Microsoft in Terminal Services) comes standard with Windows, right? Well, not really: if you're still running Windows 2000 Professional, like me, then VNC is really the only choice you have for remote desktop management since Terminal Server isn't supported on Windows 2000 Professional. There are pros and cons to both protocols and, I'll be honest, I definitely prefer RDP when possible, but I'm forced to use VNC for my home machine. VNC is, at its core, an extremely simple protocol: it's basically a remote frame buffer, so when something changes on the remote desktop, a rectangle of image data containing the changed area is sent to the client which updates its own display. This means that the protocol is inherently platform-agnostic: all you need to do is be able to take snapshots of parts of the desktop and send them to a remote client. However, this simplicity also means that the protocol is not very robust: there is no support for encryption (several implementations, such as UltraVNC, bolt this functionality onto the side, but I have been less than impressed with the results), and things like remote sounds, printer sharing, port sharing, etc. have to be carried out by separate applications. With regards to security, it's generally accepted that the best approach is to "tunnel" VNC data over other ubiquitous, secure protocols such as SSH or SSL and, thanks to the Cygwin platform, this is easily done in Windows. Using SSH as an example, here's how it works: I have an SSH server running on port 22 on my home machine and, in order to tunnel data over it, I establish a connection to it from a remote machine. However, I also specify several command line options for the SSH client process to enable local port forwarding: this means that the SSH client process will start listening on a local port and, when it receives data, it will forward it over the encrypted SSH connection and establish a connection to a local port on the remote server where it delivers the data. So, once this tunnel is set up, you instruct your VNC client to connect to the local port that the SSH client is listening on, which will receive the data, encrypt it, transmit it over the SSH connection, decrypt it, and deliver it to the port being listened to by the VNC server process on the remote machine. While this approach is great because we effectively get security for VNC for free, it also means we also have to invoke another program when trying to establish a VNC connection. Automating this process is where my application comes in.

Implementation details

Configuration

One of my favorite improvements in .NET 2.0 is the dramatically improved configuration classes: ConfigurationElement, ConfigurationSection, ConfigurationElementCollection, etc. They make it relatively painless to define configuration data classes, declaratively populate them through the App.config file, and access them programmatically (as opposed to being forced to load the config file contents into an XmlDocument object and perform XPath queries against it). The application service makes full use of this functionality to store connection profiles for tunneling setup parameters to various VNC servers. For example:

XML
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="profiles" 
      type="SecureVNCViewer.ProfileInformation, SecureVNCViewer"/>
  </configSections>
  <profiles>
    <profile name="Home" remoteHost="somewhere.somehost.com"
      viewerProgram="C:\Internet Applications\TightVNC\vncviewer.exe"
      arguments="-compresslevel 9 -quality 6 {H}::{P}" 
      connectionMode="SSH">
      <parameters>
        <parameter name="privatekeyfile" 
          value="C:\Cygwin\home\furiousgeorge\.ssh\id_rsa.ppk" />
        <parameter name="username" value="furiousgeorge" />
      </parameters>
    </profile>
  </profiles>
</configuration>

All of the data contained therein is referenced through classes that inherit from ConfigurationElement, ConfigurationSection, or ConfigurationElementCollection. The <configSections/> node contains the class and assembly reference that tells .NET's ConfigurationManager class that the <profiles/> node and all of its children are represented by the ProfileInformation class. If we look at the code for this class, we see that it is very simple:

C#
public class ProfileInformation : ConfigurationSection
{
  /// <summary>

  /// Collection of profiles that have been saved.

  /// </summary>

  [ConfigurationProperty("", IsDefaultCollection = true)]
  public ProfileCollection Profiles
  {
    get
    {
      return (ProfileCollection)this[""];
    }

    set
    {
      this[""] = value;
    }
  }
}

By flagging the Profiles property with the ConfigurationProperty attribute, it tells us that the contents of the <profiles/> node is represented by an object of type ProfileCollection (the IsDefaultCollection tells us that nodes found that are not mapped to properties of the ProfileInformation class [all of them, in this case] are elements in a ProfileCollection collection). To retrieve the information from the configuration file for a property, all you have to do is reference base["nodeOrAttributeName"] and cast it as the type the node represents. .NET's configuration classes will take care of all of the rest. This behavior follows recursively through the rest of the child nodes. An exhaustive analysis of all of the configuration classes used in this application isn't necessary since the classes basically just map nodes and attributes in the config file to properties in the classes. The MSDN documentation on the System.Configuration namespace provides a thorough coverage of the topic here.

SSH tunneling

This application can set up two types of tunnels, SSH or SSL, both of which are established in very different ways. The first tunnel type is SSH, where we rely on an excellent C# library for SSH connections called SharpSSH. The code that establishes the SSH connection is contained within the connectButton_Click() event handler method:

C#
// Set up the SSH tunnel

if (sshRadioButton.Checked)
{
  // Find an unused port on the local machine that
  // we can setup the local listener on

  connectionPort = FindUnusedLocalPort();
  connectionHost = "localhost";

  string username = "";

  // If the user specified a username in the SSH options, provide that

  if (parameters.ContainsKey("username"))
    username = parameters["username"];

  // Otherwise, show a prompt window allowing the user to enter their username

  else
  {
    UsernamePrompt usernamePrompt = new UsernamePrompt();
    DialogResult result = usernamePrompt.ShowDialog();

    if (result == DialogResult.Cancel)
    {
      usernamePrompt.Dispose();
      return;
    }

    username = usernamePrompt.Username;
    usernamePrompt.Dispose();
  }

  // Create the SSH object

  JSch ssh = new JSch();

  // Add trusted hosts from the config file so that
  // the user isn't prompted to accept them

  HostKeyRepository knownHosts = ssh.getHostKeyRepository();

  foreach (KnownHost knownHost in knownHostInformation.KnownHosts)
    knownHosts.add(knownHost.Host, Convert.FromBase64String(knownHost.Key), 
                   this);

  ssh.setHostKeyRepository(knownHosts);

  // Add the private key file to the connection if it was specified by the user

  if (parameters.ContainsKey("privatekeyfile"))
    ssh.addIdentity(parameters["privatekeyfile"]);

  sshSession = ssh.getSession(username, remoteHost);
  sshSession.setUserInfo(this);

  // Update the status bar to show that we're connecting to the remote host

  statusLabel.Text = "Connecting to remote host...";
  Application.DoEvents();

  // Establish the connection

  try
  {
    sshSession.connect();
  }

  catch (JSchException exception)
  {
    // Set the error message variable appropriately
    // if we can extract it from the exception

    Match match = Regex.Match(exception.Message, 
                              "^(?<source>[^\\:]+): (?<message>[^\r\n]+)\r\n");

    if (match.Success)
      errorMessage = match.Groups["message"].Value;
  }

  connected = sshSession.isConnected();

  // Setup port forwarding

  if (connected)
  {
    // Run the initial command, if one was specified

    if (parameters["initialcommand"] != null)
    {
      // Open a shell channel on the current connection

      ChannelShell shell = (ChannelShell)sshSession.openChannel("shell");
      shell.setOutputStream(null);

      // I have no idea what MENSA candidate coded JSCH this way, but 
      // apparently shell.getOutputStream() is the stream that actually 
      // supplies input to the shell

      StreamWriter shellInputWriter = new StreamWriter(shell.getOutputStream(), 
                                                       Encoding.ASCII);

      // Write the command to the shell's input stream

      shell.connect();
      shellInputWriter.NewLine = "\n";
      shellInputWriter.AutoFlush = true;
      shellInputWriter.WriteLine(parameters["initialcommand"] + 
                                            "; exit");

      // Wait for the command to execute and then close the channel

      while (!shell.isClosed())
        Thread.Sleep(500);

      shell.disconnect();
    }

    sshSession.setPortForwardingL(connectionPort, "127.0.0.1", remotePort);
  }
}

The first thing that we do is find an unused local port to act as the local endpoint in our SSH tunnel for the VNC data. This is accomplished by simply starting a TcpListener instance and passing 0 into its constructor's port number parameter, which tells the OS to pick any unused port for us:

C#
protected static int FindUnusedLocalPort()
{
  TcpListener portChooser = new TcpListener(IPAddress.Loopback, 0);
  int connectionPort;

  portChooser.Start();
  connectionPort = ((IPEndPoint)portChooser.LocalEndpoint).Port;
  portChooser.Stop();

  return connectionPort;
}

We then set various properties of the SSH object needed to establish the connection, such as the username (collected via a prompt, or set to the value specified in the SSH Options dialog) and the private key file, if one was specified in the Options dialog. Also, set are the various trusted hosts: when an SSH server is connected to for the first time, the user is presented with a prompt displaying the hostname of the server to which they are connecting and a fingerprint of its key. If they choose to trust this combination, then it is stored in the config file so that the next time the user connects to that host, provided that it's still using the same key, they will not receive a trust prompt. Finally, we call the setUserInfo() method of the SSH object and pass it the current class. During the connection process, it is often necessary to collect various pieces of information such as the user's password, the passphrase to their RSA key, or whether they wish to trust the host/key fingerprint combination. To do so, it invokes various callback functions of the object passed into the aforementioned method, which requires that the object implement the UserInfo interface. Next, we establish the actual connection. Once that completes, we check to see if the user specified an initial command that we should run upon connecting. If so, we open a shell channel on the current connection, and write out the command string followed by "; exit" indicating that once the command completes, the shell connection should be closed. We then sleep the thread until that close occurs. The final action that remains is to call the setPortForwardingL() method to setup port forwarding for the connection, at which point the tunnel is ready for the VNC client to connect to it.

SSL tunneling

If the user elects to tunnel their VNC data through SSL, then all of the connection and port-forwarding logic is handled in our code instead of relying on external executables. To do this, we rely on the .NET built-in class, SslStream. As an aside, this is exactly why I love .NET 2.0: we, the developers, bitched about the lack of a generic SSL class (that wasn't for HTTP), and so what did Microsoft do? They gave us one in .NET 2.0. My hat really goes off to them for stuff like this: they listened to the community, and gave us a ton of new, useful built-in classes in this version of the framework. But getting back to the application, the code that establishes the SSL tunnel is as follows:

C#
// Set up the SSL tunnel

else if (sslRadioButton.Checked)
{
  // Set up a connection to the remote SSL server and set up a local listener 
  // whose job it is to forward data it receives to the SSL connection and 
  // vice versa

  encryptedClient = new TcpClient(remoteHost, remotePort);
  encryptedStream = 
     new SslStream(encryptedClient.GetStream(), false, 
        new RemoteCertificateValidationCallback(ValidateServerCertificate), 
        new LocalCertificateSelectionCallback(SelectClientCertificate));
  unencryptedListener = new TcpListener(IPAddress.Loopback, 0);

  X509CertificateCollection clientCertificates = 
     new X509CertificateCollection();

  // Add a client certificate to the authentication request
  // if the user has specified one

  if (parameters.ContainsKey("clientCertificateFingerprint"))
    clientCertificates.Add(
       GetCertificate(parameters["clientCertificateFingerprint"]));

  // Try to establish a connection to the remote SSL server

  try
  {
    encryptedStream.AuthenticateAsClient(remoteHost, clientCertificates, 
                                         SslProtocols.Ssl3, true);

    unencryptedListener.Start();
    unencryptedListener.BeginAcceptTcpClient(
       new AsyncCallback(TunnelSSLData), this);

    connectionPort = ((IPEndPoint)unencryptedListener.LocalEndpoint).Port;
    connectionHost = "localhost";
    connected = true;
  }

  // Catch and display IOExceptions that occur, such as not being able to 
  // connect to the remote system

  catch (IOException exception)
  {
    errorMessage = (exception.InnerException != null ? 
                    exception.InnerException.Message : exception.Message);
  }

  // Catch an AuthenticationException if it's thrown; if its message 
  // indicates that the remote certificate didn't pass muster, then don't 
  // display anything else since we already displayed the certificate's 
  // details and asked the user if they wanted to trust it (any other 
  // messages are fair game and should be displayed)

  catch (AuthenticationException exception)
  {
    if (exception.Message != "The remote certificate is invalid according " 
                             + "to the validation procedure.")
      errorMessage = (exception.InnerException != null ? 
                      exception.InnerException.Message : exception.Message);

    encryptedClient.Close();
  }
}

The first thing that we do is establish a generic TcpConnection to the remote server and then construct a SslStream instance based on it. The constructor for SslStream allows us to specify a number of callback functions for events that occur during the SSL handshake process. For instance, if the remote server's certificate isn't valid (it's expired, the common name is for a domain other than the one we're connecting to, etc.), we specify a callback function to display a "do you trust this certificate?" dialog to the user with the details of the certificate, allowing them to accept or reject it. Also, if the remote server requires a client certificate to be presented for authentication, we have another callback function that provides a list of applicable client certificates. In our case, this list will be empty, or include a single client certificate: in the Options dialog for SSL, we allow the user to pick the client certificate from their local certificate store. If they don't specify one, then we don't provide a client certificate for authentication, and if they do choose one, we present only that certificate. Once the SSL connection is established, we setup the local TcpListener: similarly to the way the SSH tunnel works, the SSL tunnel will have a listener on a local port to which the VNC viewer application will connect. It will be this listener's responsibility to forward any data that it receives to the SslStream and vice versa. Once the local listener is started, we call BeginAcceptTcpClient(), which is an asynchronous method that prepares the listener to accept a connection, and then returns so that we can continue processing. We pass a callback method, TunnelSSLData, into this function that's invoked when a client connection is established. The code for this function is as follows:

C#
private void TunnelSSLData(IAsyncResult result)
{
  TcpClient unencryptedClient = 
     unencryptedListener.EndAcceptTcpClient(result);

  // Kick off the initial read operations on the streams

  unencryptedStream = unencryptedClient.GetStream();
  unencryptedStream.BeginRead(unencryptedBuffer, 0, unencryptedBuffer.Length, 
                              new AsyncCallback(ForwardUnencryptedData), 
                              this);
  encryptedStream.BeginRead(encryptedBuffer, 0, encryptedBuffer.Length, 
                            new AsyncCallback(ForwardEncryptedData), this);
}

As you can see, it's very simple: it gets the stream for the newly-established client connection, and then kicks off asynchronous, self-sustaining read operations on both the unencrypted stream (the new client stream) and the encrypted stream (the previously-established SSL stream). I say self-sustaining because the callback functions for both of these operations kick off another asynchronous read operation once they complete the current operation. The code is pretty much identical for both functions, just with the names of the read and write streams reversed:

C#
private void ForwardEncryptedData(IAsyncResult result)
{
  int bytesRead = encryptedStream.EndRead(result);

  // Only proceed if we actually read data from the stream

  if (bytesRead > 0)
  {
    // Forward the data to the local listener

    unencryptedStream.Write(encryptedBuffer, 0, bytesRead);

    // Begin the next asynchronous read operation; we wrap this in a 
    // try/catch block for ObjectDisposedException because that's what will 
    // be thrown if the VNC client exits and we kill the local listener and 
    // remote SSL client (a perfectly legitimate operation)

    try
    {
      encryptedStream.BeginRead(encryptedBuffer, 0, encryptedBuffer.Length,
                                new AsyncCallback(ForwardEncryptedData),
                                this);
    }

    catch (ObjectDisposedException exception)
    {
      string warningTrap = exception.Message;
    }
  }
}

So, it just takes the data that was read, and forwards it to the opposite stream, and then starts another asynchronous read. It traps the new asynchronous read call in a try/catch block so that it can safely exit when the stream is closed when the VNC client exits.

Running the viewer application

Once the SSH or SSL tunnel has been established, actually running the viewer application is simple:

C#
// We're connected, so start up the VNC viewer process

if (connected)
{
  // Update the status bar to indicate that we're connected
  // to the remote host

  statusLabel.Text = "Connected";
  Application.DoEvents();

  // Start the viewer process, replacing {H} with the host we're supposed to 
  // connect to (localhost for tunneling situations, the remote host 
  // otherwise) and {P} with the port we're supposed to connect to (a random 
  // local port for tunneling situations, the remote VNC port otherwise) in 
  // the program arguments specified

  ProcessStartInfo vncViewerStartInfo = 
     new ProcessStartInfo(viewerProgramTextBox.Text, 
        argumentsTextBox.Text.Replace("{H}", connectionHost).Replace("{P}", 
                                      connectionPort.ToString()));
  vncViewerStartInfo.UseShellExecute = false;

  Process vncViewerProcess = Process.Start(vncViewerStartInfo);

  // Hide the program window and wait for the viewer process to exit

  Hide();
  vncViewerProcess.WaitForExit();
}

We just start the application at the path that the user specified, and substitute strings in the command-line arguments that they provided: we substitute {H} with the the host to connect to (localhost for tunneling situations, the actual remote host otherwise), and {P} with the port to connect to (a random local port for tunneling situations, the actual remote port otherwise). We then hide our application while the viewer application is running and, after it exits, we perform any necessary cleanup on the tunnels.

Server configuration and usage

So, these new security options for VNC sound great, but you aren't sure how to configure your server for them? Well, thankfully, it's pretty easy. First, you have to decide which protocol you want to tunnel your VNC data through. Personally, I prefer SSH because you get authentication for free on a Windows machine because of the fact that, when providing a username and password, it hooks into the NT authentication framework. This means that you can tie VNC authentication to NT and turn off the VNC password prompt. To use SSH, you can download and set up the SSH server that's part of the Cygwin package. There's an excellent guide on Cygwin sshd setup here. If you choose to go the SSL route, the best option for SSL tunneling is to use the aptly-named Stunnel. Once you have Stunnel installed, you can add an entry to the stunnel.conf file like this:

[svnc]
accept  = 5900
connect = 5901

This tells Stunnel to listen on port 5900 (the :0 display port for VNC) and forward data it receives to port 5901. You'll then have to configure the VNC server to listen on port 5901.

Once the tunnel configuration is done, all that remains is to set a few configuration options for the VNC server. These instructions are for TightVNC, which is the implementation that I use, but they should be fairly similar for other implementations. First, double-click the VNC icon in your system tray, and then click the "Advanced" button. Check the "Allow loopback connections" checkbox and the "Allow only loopback connections" checkbox. This ensures that VNC can now only be accessed by a process running on the local machine (i.e., the tunneling process). If you're going with SSH, you can also set the VNC password to nothing, which will disable the VNC password prompt. Once this configuration is done, open the necessary ports in your firewall and give it a shot! When specifying arguments for the viewer application, make sure that {H}::{P} is present where the viewer application expects a hostname::port specification. {H} and {P} are essential for being able to direct the viewer application to the right hostname and port for the tunneling scenario, since the application picks a random local port to listen on for port forwarding.

History

  • 2007-02-04 - Initial publication.
  • 2007-02-07 - Added clarification for viewer arguments.
  • 2007-04-28 - Upgraded SSH tunnel to use SharpSSH instead of plink.

License

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


Written By
Software Developer (Senior) AOC Solutions
United States United States
I'm a software architect in the truest sense of the word: I love writing code, designing systems, and solving tough problems in elegant ways. I got into this industry not for the money, but because I honestly can't imagine myself doing anything else. Over my career, I've worked on just about every major Microsoft web technology, running the gamut from SQL Server 7.0 to 2008, from .NET 1.1 to 4.0, and many others. I've made a name for myself and have risen to my current position by being able to visualize and code complex systems, all while keeping the code performant and extensible.

Both inside and outside of work, I love researching new technologies and using them sensibly to solve problems, not simply trying to force the newest square peg into the round hole. From emerging Microsoft projects like AppFabric to third party or open source efforts like MongoDB, nServiceBus, or ZeroMQ, I'm constantly trying to find the next technology that can eliminate single points of failure or help scale my data tier.

Outside of work, I'm a rabid DC sports fan and I love the outdoors, especially when I get a chance to hike or kayak.

Comments and Discussions

 
QuestionTOOL USE FOR DEVOLPING THIS SOFTWARE Pin
Zia Malik11-Apr-13 20:37
Zia Malik11-Apr-13 20:37 
GeneralMy vote of 4 Pin
Member 43208448-Jun-12 11:23
Member 43208448-Jun-12 11:23 
Questionconnection Pin
kkcarthieckk9-Jan-12 18:40
kkcarthieckk9-Jan-12 18:40 
QuestionI must be missing something Pin
Net Nut17-Aug-11 12:18
Net Nut17-Aug-11 12:18 
AnswerRe: I must be missing something Pin
Luke Stratman19-Aug-11 2:59
Luke Stratman19-Aug-11 2:59 
GeneralUse VNC over the Internet Pin
redboy200019-Nov-08 4:20
redboy200019-Nov-08 4:20 
I'd admit the tool is cool. But what if I want to use it cross Internet like access my home pc from office, where the secure is really needed. I think probably I still need a VPN solution like NeoRouter , hamachi or windows built-in pptp, but most of this kind of software provide SSL encryption feature ( NeoRouter supports SSL, while hamachi supports its own encrypt algrithm ). So to be faster, the VNC packets do not really need to encrypt again.

jingqianghua

Questionpassword rather than privatekey? Pin
Rob Achmann16-Aug-08 2:20
Rob Achmann16-Aug-08 2:20 
GeneralGreat Project, Having Issues Compiling Pin
sageturkey25-Jun-08 4:12
sageturkey25-Jun-08 4:12 
AnswerRe: Great Project, Having Issues Compiling Pin
Luke Stratman25-Jun-08 12:39
Luke Stratman25-Jun-08 12:39 
GeneralRe: Great Project, Having Issues Compiling Pin
sageturkey26-Jun-08 16:07
sageturkey26-Jun-08 16:07 
QuestionWhy not just use a VPN? Pin
daved194815-Jun-07 19:39
daved194815-Jun-07 19:39 
GeneralA small security issue [modified] Pin
DSCIVlad15-May-07 1:15
DSCIVlad15-May-07 1:15 
Questiontunneling across NAT Pin
sunnchcl10-Apr-07 21:58
sunnchcl10-Apr-07 21:58 
Questionrdp connection only through ssh Pin
sunhcl11-Apr-07 2:07
sunhcl11-Apr-07 2:07 
AnswerRe: rdp connection only through ssh Pin
Luke Stratman1-Apr-07 12:34
Luke Stratman1-Apr-07 12:34 
QuestionRe: rdp connection only through ssh Pin
sunhcl11-Apr-07 15:57
sunhcl11-Apr-07 15:57 
AnswerRe: rdp connection only through ssh Pin
Luke Stratman1-Apr-07 16:18
Luke Stratman1-Apr-07 16:18 
QuestionRe: rdp connection only through ssh Pin
sunhcl11-Apr-07 18:26
sunhcl11-Apr-07 18:26 
AnswerRe: rdp connection only through ssh Pin
sunhcl12-Apr-07 0:48
sunhcl12-Apr-07 0:48 
GeneralTrouble Pin
schwieb7-Feb-07 4:45
schwieb7-Feb-07 4:45 
GeneralRe: Trouble Pin
Luke Stratman7-Feb-07 6:13
Luke Stratman7-Feb-07 6:13 
GeneralRe: Trouble Pin
schwieb7-Feb-07 10:11
schwieb7-Feb-07 10:11 
GeneralRe: Trouble Pin
Luke Stratman7-Feb-07 15:22
Luke Stratman7-Feb-07 15:22 
GeneralRe: Trouble Pin
schwieb8-Feb-07 2:08
schwieb8-Feb-07 2:08 
GeneralVNC Enterprise Pin
glronco6-Feb-07 7:43
glronco6-Feb-07 7:43 

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.