Click here to Skip to main content
Click here to Skip to main content

Remote Administration using telnet and HTTP

, 22 Feb 2012 CPOL
Rate this:
Please Sign up or sign in to vote.
Embedding a telnet and HTTP server in an application
RemoteAdmin

Introduction

In this article, I'll demonstrate how we can provide remote administration functionality to our applications by embedding a small extendable telnet or web server. I'll provide code to create both types of embeddable servers as well as an extendable set of classes encapsulating each servers basic unit of work (a web page or telnet command). I'll also briefly cover how we can create client side applications to allow for some simple remote scripting of our servers.

Creating an Embeddable telnet Server

I'll begin with embedding a telnet server into an application. First we need just a little background on the telnet protocol. The protocol consists of Protocol bytes and data bytes. Protocol bytes are commands or options that relate directly to the protocol. How the session should be conducted. Data bytes are those that pass from client to server and vice versa without regard for the protocol used to send them. For example, a client requests a directory listing and the server replies with the file names. This data is specific to the processes on each end of the connection. We'll be dealing with a simplified subset of the protocol which I enumerate below. The Telnet protocol commands are sent in groupings of either two or three bytes. The leading byte is always IAC or "Interpret As Command". Receiving this byte informs us that the next byte (or two) will be a telnet specific instruction. Our server will only be using three byte commands and we'll just be setting a couple options when the client first connects. While our server needs to be aware of inbound protocol bytes and separate them from the data bytes, it won't be taking any actions based on protocol bytes received.

enum TelnetOpCodes
{
    ECHO = 1, SUPPRESSGO = 3,
    WILL = 251, WONT = 252,
    DO = 253, DONT = 254,
    IAC = 255
}

On instantiation, the server immediately spawns a thread and begins listening for client connections. Acceptance of a connection results in a secondary thread being spawned where the actual communication is done. I'll assume you have familiarity with sockets and threads so I won't elaborate here on those details. We'll start at the point where the connection has been established. The first thing the server does is send down a couple session related options to the client.

private void runServer(Socket S)
{
    try
    {
        m_Connections.Add(S);
        S.Send(new byte[]
        {
            (byte)TelnetOpCodes.IAC,
            (byte)TelnetOpCodes.DONT,
            (byte)TelnetOpCodes.ECHO
        });
        S.Send(new byte[]
        {
            (byte)TelnetOpCodes.IAC,
            (byte)TelnetOpCodes.WILL,
            (byte)TelnetOpCodes.SUPPRESSGO
        });
        while (!string.IsNullOrEmpty(m_sLogin) && !processLogin(S)) { };
        processRequests(S);
        S.Close();
    }
    catch { }
}

The first set of commands sent to the client are "don't echo back any keystrokes". Our server will perform this function. This gives us the ability to hide the user password as it's typed in. Next we send the "Suppress Go" command. Suppress Go is a legacy option of the protocol. Within the protocol is the ability to function in what is commonly referred to as half duplex mode. That means the client and server will take turns sending data. When one side is finished transmitting, it sends a "Go" byte down the wire. It then becomes the other sides turn to transmit. We have no interest in supporting this option so we're going to inform any client that connects that we don't by sending "Suppress Go".

With the session options set optional support for processing a login is provided. Once a login has been accepted the server begins processing requests from client to server (commands entered at a command prompt by a user). Below I've included a portion of the function "processLogin". I'll use this piece of code to provide detail as to how inbound data is processed. The separation of the protocol bytes from the data bytes.

private bool processLogin(Socket S)
{
    List<byte> DataBytes = new List<byte>();
    List<byte> ProtocolBytes = new List<byte>();

    string sLogin = "";
    string sPwd = "";
    S.Send(Encoding.ASCII.GetBytes("\r\nlogin: "));

    while (DataBytes.Count < 1)
    {
        TelnetSocketReader.ReadData(S,
          new KeyValuePair<List<byte>,List<byte>>(ProtocolBytes, DataBytes));
        processProtcolData(ProtocolBytes);
    }
    sLogin = DataUtils.BufferToString(DataBytes.ToArray(), DataBytes.Count);
    sLogin = sLogin.Replace("\n", "");
    sLogin = sLogin.Replace("\r", "");
    DataBytes.Clear();
    ProtocolBytes.Clear();

Two byte lists have been declared. One for the protocol bytes received, one for the data bytes. Variables are also declared to hold the login and password received from the client. We loop until we have data bytes to use for the login, convert the bytes to a string, clean off the line feed carriage return, and clear the byte lists. As stated, no processing of protocol bytes is done aside from separating them from the data bytes. This procedure is repeated for the password and then both login and password are checked for a match. The actual work of pulling the bytes from the socket and separating protocol bytes from data bytes is done in the static function "ReadData" in the TelnetSocketReader class. We'll take a look at that next.

static public void ReadData(Socket S,
    KeyValuePair<List<byte>,List<byte>> ExtractedBytes,
    bool bEcho)

The function takes the socket the client is connected on, a KeyValuePair of byte lists, and a boolean flag. The key of the KeyValuePair holds protocol bytes and the value data bytes. The boolean indicates if received keystrokes should be echoed back to the client. We create local references to the protocol and data byte lists, establish a buffer of the bytes we receive, and declare a variable to contain the string representation of the data bytes received.

List<byte> ProtocolBytes = ExtractedBytes.Key;
List<byte> DataBytes = ExtractedBytes.Value;
byte[] InboundBuffer = new byte[1];
string sCmd = "";

ReadData's processing loop then begins and continues to process inbound bytes until either it has received a complete set of protocol bytes or a carriage return line feed has been received (signaling the end of a set of data bytes). We process our inbound bytes one at a time (this isn't a high performance telnet server).

Inside the processing loop (below), if the last byte we've received is an IAC byte (Interpret As Command)... process protocol bytes.

if (InboundBuffer[0] == (byte)TelnetOpCodes.IAC)
{
    S.Receive(InboundBuffer); //Pull the protocol command off the socket
    int iCmd = InboundBuffer[0];
    switch (iCmd)
    {
        case (int)TelnetOpCodes.DO:
        case (int)TelnetOpCodes.DONT:
        case (int)TelnetOpCodes.WILL:
        case (int)TelnetOpCodes.WONT:
            {
                //Pull the protocol option off the socket
                S.Receive(InboundBuffer);
                ProtocolBytes.AddRange(new byte[]
                {
                    (byte)TelnetOpCodes.IAC,
                    (byte)iCmd,
                    (byte)InboundBuffer[0]
                });
                break;
            }
        default:
            break;
    }
    if (ProtocolBytes.Count < 1)
    {
        ProtocolBytes.AddRange(new byte[] { (byte)TelnetOpCodes.IAC,
           (byte)iCmd });
    }
    return;
}

Still inside ReadData's processing loop, as we receive non-protocol bytes, we add them to our DataBytes byte list. When a line feed carriage return is received, return. Note, some limited command line editing is provided by handling the backspace key.

else
{
  if (InboundBuffer[0] == (byte)Keys.Back)
  {
    //Some limited command line editing here, process a backspace.
    //Pull the last byte we received off DataBytes
    //Then send appropriate response.
    if (DataBytes.Count > 0)
    {
      DataBytes.RemoveAt(DataBytes.Count - 1);
      S.Send(new byte[] { 27, 91, 68, 27, 91, 75 });
    }
  }
  else
  {
    DataBytes.Add(InboundBuffer[0]);
    if (bEcho)
      S.Send(InboundBuffer);
    sCmd += DataUtils.BufferToString(InboundBuffer, iBytesRecd);
    if (sCmd.EndsWith("\r\n"))
      return;
  }
}

Once login processing has successfully completed, the function "processRequests" is invoked. This is where we process the commands exposed by our server. Upon entering the function, a command prompt is defined and immediately sent down to the connected client. Like processLogin, two byte lists are declared (for protocol and data bytes). Similarly, the connected socket and byte lists are passed to "ReadData". The function then enters a loop processing commands. As data bytes are received, they are converted to characters and appended to the current command string. When the command string is terminated with a carriage return line feed, it is considered a complete command and ready for processing. For our basic implementation, we simply echo the command in its entirety back to the client along with the string "Received". When the "exit" command is received, the loop is terminated and the session ended.

private void processRequests(Socket S)
{
    string sPrompt = "\r\n> ";
    S.Send(Encoding.ASCII.GetBytes(sPrompt));

    List<byte> DataBytes = new List<byte>();
    List<byte> ProtocolBytes = new List<byte>();
    string sCmd = "";
    while (true)
    {
        TelnetSocketReader.ReadData(S,
        new KeyValuePair<List<byte>,List<byte>>(ProtocolBytes,
            DataBytes));
        processProtcolData(ProtocolBytes);
        sCmd += DataUtils.BufferToString(DataBytes.ToArray(),
            DataBytes.Count);
        if (sCmd.EndsWith("\r\n"))
        {
            DataBytes.Clear();
            sCmd = sCmd.Replace("\n", "");
            sCmd = sCmd.Replace("\r", "");
            if (sCmd.ToLower() != "exit")
            {
                S.Send(Encoding.ASCII.GetBytes("\r\nReceived "
                    + sCmd + "\r\n"));
            }
            else
            {
                return; //Client requests to terminate connection
            }
            sCmd = "";
            S.Send(Encoding.ASCII.GetBytes(sPrompt));
        }
    }
}

That completes the implementation of a simple embedded telnet server. Next we'll move on to implementing an http server. Following the implementation of that server, we'll circle back around and provide the ability to add specific commands/actions to both our telnet and http servers.

Creating an Embeddable HTTP Server

Web servers use a different protocol. HTTP or Hypertext Transfer Protocol. Fortunately starting with .NET 2.x, we have the HttpListener class. Thanks to HttpListener implementing a server supporting the protocol has become much, much easier. The class handles the socket IO and with a handful of property settings parses our inputs and formatting our outputs. This class is a pleasure to use. I've created embedded web servers in a number of languages managing the socket IO directly and parsing and formatting the http. It's an enjoyable challenge but it's also fertile ground for error. It's nice to be able to simply link in this functionality.

Below we have the implementation of the public interface and member variables of our embedded http server.

The Class "WebServer"

class WebServer
{
    private int m_iPort = 0;
    HttpListener m_HttpListener = new HttpListener();
    public WebServer(int iPort)
    {
        m_iPort = iPort;
        m_HttpListener.Prefixes.Add("http://+:" + m_iPort + "/");
    }
    public void Start()
    {
        if (!m_HttpListener.IsListening)
        {
            m_HttpListener.Start();
            new Thread(new ThreadStart(runServer)).Start();
        }
    }
    public void Stop() { m_HttpListener.Stop(); }
    public bool IsRunning() { return m_HttpListener.IsListening; }

Basically a thin wrapper around HttpListener. One point of particular interest here. Adding the string http://+:" + m_iPort + "/" to the prefixes collection specifies that the listener socket should listen on all available network interfaces.

The "Start" method spawns a separate thread using the function "runServer". This is where the listening and subsequent IO occur. The implementation of runServer appears below:

private void runServer()
{
    try
    {
        while (m_HttpListener.IsListening)
        {
            HttpListenerContext Context = m_HttpListener.GetContext();
            HttpListenerResponse Response = Context.Response;
            byte[] PageData = Encoding.UTF8.GetBytes
            (
                "<html><body><h1>Page " + Context.Request.RawUrl
                    + " Requested</h1></body></html>"
            );
            Response.StatusCode = (int)HttpStatusCode.OK;
            Response.ContentLength64 = PageData.Length;
            Response.ContentEncoding = Encoding.UTF8;
            Response.OutputStream.Write(PageData, 0, PageData.Length);
            Response.OutputStream.Close();
            Response.Close();
        }
    }
}

To summarize the function, while the listener object is in a listening state, we repeatedly request from it a "Context" object by calling "GetContext". Basically a request for a page. The call to GetContext blocks until we get a request or the listener is closed. Once we have a Context, we can use it to access its "Response" property (an HttpListenerResponse object). We then send the page text back to the client by writing it to the HttpListenerResponse object (making sure to close it when we're finished). That's about all that is needed to embed a minimal implementation of an http server into an application.

Adding Basic Functionality to Our Servers

With our servers in place, it's time to give them something useful to do. In the attached sample, I've provided the servers with the ability to perform a couple of basic tasks. Specifically, display a message to users of the application, dump a thread stack to the screen of a client (browser), and provide the ability to restart the application remotely. Due to space constraints however within the article, I'll only go over the supporting changes required of the servers and develop a simple set of base classes that can be extended and used to add commands to the servers.

We'll start with the web server by defining a class that encapsulates a request for a page. The page object will have an identifier (its associated URL) and return the content for its associated page. Below is the basic page definition:

class WebPage
{
    protected string m_sPage = "";
    protected string m_sContent = "";
    public WebPage(string sPage)
    {
        m_sPage = sPage;
    }
    public WebPage(string sPage, string sContent)
    {
        m_sPage = sPage;
        m_sContent = sContent;
    }
    public string Page { get { return m_sPage; } }
    public virtual List<byte> GetContent()
    {
        return new List<byte>(Encoding.UTF8.GetBytes(m_sContent));
    }
}

We'll extend our server to accept a list of these objects on construction and serve content based on that list. We'll also have each page request run in its own thread. We do this by adding the following member variable to the server class:

Dictionary<string, WebPage> m_Pages = new Dictionary<string, WebPage>();

And changing the server class constructor to the following:

public WebServer(int iPort, List<WebPage> WebPages)
{
    m_iPort = iPort;
    m_HttpListener.Prefixes.Add("http://+:" + m_iPort + "/");
    if (WebPages != null)
    {
        foreach (WebPage WP in WebPages)
        {
            if (!m_Pages.ContainsKey(WP.Page.ToLower()))
                m_Pages.Add(WP.Page.ToLower(), WP);
        }
    }
}

The main loop in the runServer function then becomes what we see below:

while (m_HttpListener.IsListening)
{
    HttpListenerContext Context = m_HttpListener.GetContext();
    Thread RequestThread = new Thread(
        new ParameterizedThreadStart(processRequest));
    KeyValuePair<HttpListenerContext, WebPage>? Param = null;
    if (m_Pages.Count > 0)
    {
        if (m_Pages.ContainsKey(Context.Request.RawUrl.ToLower()))
        {
            Param = new KeyValuePair<httplistenercontext, WebPage>(
                Context, m_Pages[Context.Request.RawUrl.ToLower()]);
        }
        else if (Context.Request.RawUrl == "/")
        {
            Param = new KeyValuePair<httplistenercontext, WebPage>(
                Context, new WebPage("/", "Default Page"));
        }
        else
        {
            Param = new KeyValuePair<httplistenercontext, WebPage>(
                Context, new WebPage("", "Page Not Found"));
        }
    }
    else
    {
        Param = new KeyValuePair<httplistenercontext, WebPage>(
            Context, new WebPage("/", "Default Page"));
    }
    if(Param != null)
        RequestThread.Start(Param);
}

A new function, "processRequest", has been added to extract the contents of the page and write it to the socket.

private void processRequest(object O)
{
    KeyValuePair<HttpListenerContext, WebPage>? ContextAndPage
        = O as KeyValuePair<HttpListenerContext, WebPage>?;
    HttpListenerResponse Response = ContextAndPage.Value.Key.Response;
    byte[] PageData =
        ContextAndPage.Value.Value.GetContent().ToArray();
    Response.StatusCode = (int)HttpStatusCode.OK;
    Response.ContentLength64 = PageData.Length;
    Response.ContentEncoding = Encoding.UTF8;
    Response.OutputStream.Write(PageData, 0, PageData.Length);
    Response.OutputStream.Close();
    Response.Close();
}

We now have the ability to easily extend our web server with additional pages that can perform various actions and return the results. Basically all that needs to be done is derive from WebPage and override of the "GetContent" method.

Next I'll show a similar method to add commands to our telnet server. Similar in function to WebPage, I'll define a class "TelnetCommand". The implementation is as follows:

class TelnetCommand
{
    protected string m_sCommand;
    protected string m_sResponse;
    public TelnetCommand(string sCommand)
    {
        m_sCommand = sCommand;
    }
    public TelnetCommand(string sCommand, string sResponse)
    {
        m_sCommand = sCommand;
        m_sResponse = sResponse;
    }
    public string Command
    {
        get { return m_sCommand; }
    }
    public virtual string ExecuteCmd()
    {
        return m_sResponse + "\r\n";
    }
}

Pretty straightforward. Also similar to the changes to the "WebServer" class, I'll now extend "TelnetServer" to leverage the new telnet command class. Below, a member variable to hold telnet commands is declared.

private Dictionary<string, TelnetCommand> m_Cmds =
    new Dictionary<string, TelnetCommand>();

Next we'll modify our telnet server's constructor to take a list of these command objects.

public TelnetServer(int iPort, List<TelnetCommand> Cmds)
{
    m_iPort = iPort;
    foreach (TelnetCommand TC in Cmds)
        if (!m_Cmds.ContainsKey(TC.Command))
            m_Cmds.Add(TC.Command, TC);
}

And finally, we change the "processRequests" function in our telnet server to work with our new command objects.

if (sCmd.ToLower() != "exit")
{
    if (m_Cmds.ContainsKey(sCmd))
    {
        S.Send(Encoding.ASCII.GetBytes(m_Cmds[sCmd].ExecuteCmd()));
    }
    else
    {
        S.Send(Encoding.ASCII.GetBytes("\r\n" + sCmd
           + " - Command Not Found\r\n"));
    }
}
else
{
    return; //Client requests to terminate connection
}

So like our web server, our telnet server now has the ability to be extended with additional commands. All that needs to be done is implement a derivation of TelnetCommand and override the "ExecuteCommand" function.

Scripting our Servers with Simple Clients

With the server side completed, we can leverage that functionality not only to perform tasks remotely but also to script those tasks that may lend themselves to automation. In this section, I'll cover the basics of creating simple clients that can connect to our servers, execute commands, and retrieve the results. We'll start with creating a client that interacts with our web server. It is by far the simpler of the two, thanks to the WebClient class provided by the .NET framework. Below is the code. The variable "sURL" is a string containing the complete URL to connect to. Really trivial to implement.

WebClient WC = new WebClient();
byte [] Data = WC.DownloadData(sURL);
string sData = DataUtils.BufferToString(Data, Data.Length);
Console.WriteLine(sData);   

The telnet server client on the other hand is more involved. There is no direct support for a telnet client in the .NET Framework. As such, we must connect with a socket and parse the bytes received. Given we control the bytes sent from the server, the work is significantly easier than connecting to a random telnet server on our network and executing commands. We will however need to separate the telnet protocol bytes from data bytes received. We'll also need to parse any login/password prompt sent to us and reply appropriately. The constructor for our telnet client is below. We pass to it the server and port to connect to, the login prompt we are expecting, the login to respond with, the password prompt, the password to use, the commandline prompt we expect, and finally, the command to execute.

public TelnetClient(string sServer, int iPort,
    string sLoginPrompt, string sLogin, string sPwdPrompt,
    string sPwd, string sPrompt, string sCmd)

The work of connecting and executing the command centers around the function "initTelnetClient". There we create our socket and connect to the remote server. We then loop sending and receiving data until the command has been executed and its results received.

protected virtual void initTelnetClient()
{
    Socket S = new Socket(AddressFamily.InterNetwork,
        SocketType.Stream, ProtocolType.Tcp);
    S.Connect(m_sServer, m_iPort);
    List<byte> DataBytes = new List<byte>();
    List<byte> ProtocolBytes = new List<byte>();
    while (!m_bExit)
    {
        TelnetSocketReader.ReadData(S, new KeyValuePair<List<byte>,
            List<byte>>(ProtocolBytes, DataBytes));
        List<byte> SendBytes = new List<byte>();
        if (ProtocolBytes.Count > 0)
        {
            SendBytes.AddRange(
               processProtocolBytes(ProtocolBytes).ToArray());
        }
        if (DataBytes.Count > 0)
        {
            SendBytes.AddRange(
               processDataBytes(DataBytes).ToArray());
        }
        if(SendBytes.Count>0)
        {
            S.Send(SendBytes.ToArray());
        }
    }
}

Within initTelnetClient, the function "ReadData" is a convenience function included in the sample scripting application. It's implementation differs slightly from the version in the server. It's function is to simply pull the data off the socket and separate telnet protocol bytes from data bytes. The function "processDataBytes" incrementally processes each data byte received. It is passed the cumulative bytes received from the server and returns any bytes that are to be sent as a response.

protected virtual List<byte> processDataBytes(List<byte /> Bytes)

If a login has been specified, it first scans the bytes received for the login prompt. It returns the bytes to login with when that prompt is encountered.

List<byte> RetVal = new List<byte>();
string sData = DataUtils.BufferToString(
    Bytes.ToArray(), Bytes.Count);

if (!m_bLoginSubmitted && !string.IsNullOrEmpty(m_sLoginPrompt)
    && !string.IsNullOrEmpty(m_sLogin))
{
    if (sData.EndsWith(m_sLoginPrompt))
    {
        m_bLoginSubmitted = true;
        RetVal.AddRange(Encoding.UTF8.GetBytes(m_sLogin
           + "\r\n"));
        Bytes.Clear();
    }
}

A similar procedure is followed for the password.

else if (m_bLoginSubmitted && !m_bPwdSubmitted
    && !string.IsNullOrEmpty(m_sPwdPrompt)
    && !string.IsNullOrEmpty(m_sPwd))
{
    //We have a pwd prompt but have not submitted a pwd
    if (sData.EndsWith(m_sPwdPrompt))
    {
        m_bPwdSubmitted = true;
        RetVal.AddRange(Encoding.UTF8.GetBytes(m_sPwd + "\r\n"));
        Bytes.Clear();
    }
}

A Login is considered successful once the login and password responses are sent and the string specified as the command prompt has been received. At this point, the command to execute on the remote server is sent and an exit flag is set. The client application then closes its socket and disconnects from the server.

else if(m_bLoginSubmitted && m_bPwdSubmitted)
{
    //Process Data. We process one line at a time.
    //Clearing the passed list each time we receive a line
    if (sData.EndsWith(m_sPrompt))
    {
        if (!string.IsNullOrEmpty(m_sCmd))
        {
            RetVal.AddRange(Encoding.UTF8.GetBytes(
               m_sCmd + "\r\n"));
            m_sCmd = string.Empty;
        }
        else
        {
            //Results from the command we executed
            m_bExit = true;
        }
        Bytes.Clear();
    }
}

Regarding the processing of protocol bytes... with the telnet server we've implemented the only thing our scripting client needs to do is to separate protocol bytes from data bytes. Although our server does send protocol bytes at the initial connection, it does not require a response to them. That said, the "processProtocolBytes" function is capable of returning responses for those protocol options that it receives.

protected virtual List<byte> processProtocolBytes(List<byte> Bytes)

In the scripting client, I've implemented a simple response for a handful of protocol commands. I've done this to allow for a more generic usage of the client, attaching it to a general purpose telnet server for example. In testing, I found this adequate for a telnet server on a Ubuntu box residing on my network. Allowing the scripting client to connect and execute commands. Basically the function returns a telnet "WONT" response along with the telnet option that was received.

List<byte> RetVal = new List<byte>();
if (Bytes.Count > 2 && Bytes[0] == (byte)TelnetOpCodes.IAC)
{
    int iOpCode = (int)Bytes[2];
    switch (iOpCode)
    {
        case (int)TelnetOpCodes.NEWENV:
        case (int)TelnetOpCodes.TSPEED:
        case (int)TelnetOpCodes.XLOCAL:
        case (int)TelnetOpCodes.TTYPE:
        case (int)TelnetOpCodes.NAWS:
            RetVal.AddRange(new byte[]
            {
                (byte)TelnetOpCodes.IAC,
                    (byte)TelnetOpCodes.WONT, Bytes[2]
            });
            break;
        default:
            break;
    }
}
Bytes.Clear();
return RetVal;

While this was sufficient to allow the scripting client to connect and execute a command on the Ubuntu server, a telnet server running on a local Solaris machine would have none of it. That server appeared to stay waiting for a response to a protocol request sent to the client. Placing a sniffer application between the client and the server showed the protocol bytes sent down from the server on connection differed significantly from those sent from the server residing on the Ubuntu machine. In short, your mileage may vary. For more general usage of the telnet scripting client, I would suggest using it in conjunction with a program like NetCat.

Summary

In this article, I've covered the basics of how we can administer applications remotely by embedding a small telnet or web server. This functionality lends itself to gathering statistics, debugging, and performing routine maintenance tasks. I've gone over extending the server with support for re-usable base classes that encapsulate the basic unit of work on each type of server. In addition, I've spent some time discussing how we can create scripting clients to automate tasks. This article and the attached samples should serve only as a guide for potential implementations. A basis for ideas. In an actual production application, due consideration should be given to security when providing administrative backdoors. Bear in mind also that the protocols discussed here are clear text protocols and come with their own set of caveats.

History

  • 21st February, 2012: Initial version

License

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

Share

About the Author

Phoenix Roberts
President Phoenix Roberts LLC
United States United States
We learn a subject by doing but we gain a real understanding of it when we teach others about it.

Comments and Discussions

 
QuestionBrilliant PinmemberCleveland Mark Blakemore2-Jul-13 21:47 
GeneralMy vote of 5 PinmemberDavide Zaccanti20-Aug-12 0:39 
GeneralMy vote of 5 PinmemberSergio Andrés Gutiérrez Rojas27-Feb-12 10:21 
GeneralMy vote of 5 Pinmemberknoami26-Feb-12 2:31 
GeneralMy vote of 5 PinmemberWooters23-Feb-12 8:57 

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

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

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.141223.1 | Last Updated 22 Feb 2012
Article Copyright 2012 by Phoenix Roberts
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid