Remote Shell - Version 1

Remote Shell - Version 1

Introduction

This assignment will be the first in a series of assignments in which you will develop a "remote shell" facility. Your remote shell will be based loosely on the rsh command that comes with most Unix systems, but unlike your previous project for this course, your remote shell will not attempt to mimic any existing command precisely.

Project Overview

The nominal goal of these assignments is to develop a facility that allows users to enter command lines on one computer and to have them execute on another computer. To do this, you will need to implement two programs, a client, which will read command lines typed by a user and pass them on to a remote server, which will process each command line and return the results to the client, which will display them on the user's console.

You will begin by developing the infrastructure for linking a client and server, and then add functionality built on this infrastructure in successive assignments.

Project Management

You are to use a single project directory for this assignment and all following assignments in this course. The Makefile and all source files for the assignment are to be under RCS management as described in the Coding Guidelines for the course. There is a Skeleton Makefile you may use as a starting point for using the make command properly.

This Assignment

This assignment is Assignment 3 for the course. All files you submit for this assignment must have RCS revision numbers in the form 1.xxx, where xxx may be any number between 1 and 99. But the first two characters of the revision numbers must be "1.".

For this assignment, you are to implement a server program to which client programs can connect. The server will recognize several command line options, will manage a log file in which it will record its activity, and will accept connections from clients. But it will not actually do anything having to do with its role as a command processor yet.

You will also implement a simple client program that you will be able to use to test your server.

The Server

The server is to be an executable file named qserver. It is to be built from at least the three source files listed below. You may add extra source files to the project if you wish.

File Name Contents
qserver.h The header file included in all other source files used to build the qserver executable.
qserver.cc Contains the main() function for the program.
(May also contain other functions, but does not have to.)
logfile.cc Contains the functions, openLog(), writeLog(), and closeLog() described below.
(May also contain other functions, but does not have to.)

The server is to recognize the following command line options:

-? | --help Displays a usage message and exits the program.
-p | --port This option must be followed by an argument, the "well-known" port number, which the server will use for accepting connections. The user may enter the port number as a decimal, octal, or hexadecimal constant using the syntax rules for numeric literals in C/C++. If this option is not specified, the port number defaults to the last four digits of your social security number. If the last four digits of your social security number has a decimal value less than 1024, add 1024 to it to get the default value for this parameter.

The server binds a socket to it's well-known port number, and uses this socket to accept connections from clients. Port numbers between 0 and 1023 cannot be used unless the program is being run by the super user. (Optional: Let the super user specify bind to port numbers below 1024.) Because port numbers are 16-bit unsigned numbers, the value entered by the user must be between 0 and 65,535 (that is, between 0x0000 and 0xFFFF, which is between 00000 and 0177777).

Use the standard library function strtol() to convert the user's command line argument from a string to a numeric port number.

-l | --logfile The server will write messages to a file as it runs. (See below.) The argument for this option specifies the pathname for this file. The default pathname is to be "./qserver.log".
-o | --overwrite This option does not take an argument. If it is specified, the server is to overwrite the logfile, if it exists. The default behavior is that the server will append messages to the logfile if it exists. "The logfile" means the file that will be used for log messages during this invocation of the server.
-v | --version This option is to cause the server to print a message of the form: "qserver: $Revision 1.1 $". The part of this message that is surrounded by dollar signs is to be produced using the $Revision$ RCS keyword, so the number will be the actual revision number for your qserver.cc source file. The server exits immediately after printing the message if this option is specified.

The Log File

The server is to write a message to a log file each time a "significant" event occurs. Each message is to include the date and time at which the event occurred, and information about what the event was. The date and time are to be in the format "YYYY-MM-DD HH:MM:SS" (4-digit year, 2 digit month, day, hour, minute, and seconds), which will be referred to as a timestamp.

You can determine the current time and date using the time(2) kernel call, and you can create timestamp strings using the strftime(3) function. You can use the localtime(3) function to convert the time_t value returned by time(2) into a struct tm * required by strftime(3).

The table below gives the prototypes and requirements for the three functions that are required to be defined in logfile.cc.

Prototype Description
int
openLog( const char *pathname,
         const char *msg,
         bool        overwrite );
Open the log file at pathname for writing if possible. Open the file in append or overwrite mode depending on the value of overwrite.
Return -1 if the file cannot be opened.

If the log file was opened successfully, write two lines to it. The first line is to be the current timestamp followed by the word "STARTUP." The second line is to be the msg argument, indented four spaces (or an empty line if msg is a null pointer). Return 0 if no errors occur.

int
writeLog( const char *event_name,
          const char *msg );
Return -1 if the log file has not been opened.
Otherwise, write two lines to the log file. The first line is to be the current timestamp followed by the event_name string (or an empty string if event_name is a null pointer). The second line is to be the msg argument, indented four spaces (or an empty line if msg is a null pointer). Return 0 if no errors occur.
int
closeLog( const char *msg );
Return -1 if the log file has not been opened.
Otherwise, write two lines to the log file. The first line is to be the current timestamp followed by the word "SHUTDOWN." The second line is to be the msg argument, indented four spaces (or blank line if msg is a null pointer). Close the log file and return 0 if no errors occur.

Server Features

For this assignment, the server will act as an iterative server. That is, it will interact with just one client at a time. The algorithm for this version of the server is:

How to Implement the Server

Limited as its functionality is, the server uses many features of the Unix kernel that we have not fully covered in class. What follows are guidelines for implementing these various features. These guidelines are presented as a sequence of development steps and "how-to" information for each step. After coding and testing a step carefully, check in the file(s) you worked on so that each step will correspond to a new RCS revision number for at least one source file.

Step 1. Set up the project.

Create your Makefile, starting with the Skeleton Makefile, qserver.h, qserver.cc, and logfile.cc. Code the main() function to process the command line arguments and print a message giving the values of the options so you can be sure they are being handled correctly. Note that option processing is a bit harder for this assignment than for the wc project because some of the options take arguments.

When you look at the Skeleton Makefile, you will see that it redefines the CXXFLAGS variable to include the reference, $(DEBUG_FLAG). The idea behind doing this is that if DEBUG_FLAG is not set in your environment when you run make to compile your code, this reference will have no effect because undefined variables expand to nothing. But if you set this variable to -DDEBUG, either in your shell or on the make command line, then the C Preprocessor will run with the symbol DEBUG defined. (You can type "DEBUG_FLAG=-DDEBUG make" to do this.) For example, to check the result of option processing, you could insert code that looks like this in main():

#ifdef DEBUG
  printf( "Logfile: %s (%s)\n"
          "Port:    0x%04X (%d)\n",
          logfilePath, (overwriteLogfile ? "overwrite" : "append" ),
          wellKnownPort, wellKnownPort );
#endif
By compiling with DEBUG set, you can test various combinations of command line options and use the output printed by the code above to see if they were handled correctly. Once you are satisfied with those tests, you can simply omit the -DDEBUG setting, and the above code will effectively be removed from your program without any editing changes on your part. This is a somewhat crude use of conditional compilation (What if you want to debug another part of the program and not see the option processing output?), but you should become familiar with the technique.

Don't lose track of the fact that learning how to use the project management tools (make and rcs) is a key goal of this assignment. Use this assignment to help make sure you are fully comfortable with these tools.

Assuming lnx0006 and spinoza are both available, be sure your code runs correctly on both systems before going on to the next step. (This rule applies to each step of the development process.) Be sure "make clean" leaves you with a clean project directory. Be sure you have run "make depend" before you check in your Makefile. Be sure "make" with no arguments builds qserver from a clean project directory. (The make command automatically checks out everything it needs, including the Makefile, when there is an RCS subdirectory.)

Step 2. Implement the Log File Functions

Now remove the code that printed the results of command line processing (or leave out the -DDEBUG setting if you followed the strategy described above), and re-code main() so that it exercises the log file functions by opening the log file, writing a message or two to it (use "TESTING" as the event name and anything you like as the messages), and closing it. You should use the sleep(3) function so there is a delay of a few seconds between opening and closing the log file to make sure the timestamp operations are working correctly.

Step 3. Accept Connections From Clients.

In class, we went over the code to set up the server to wait for client connections on its "well-known" socket. Here is semi-pseudo code that does it:
    gethostname( thisHostName, MAX_HOST_NAME );
    hp = gethostbyname( thisHostName );
    memset( &server_sockaddr, 0, sizeof( server_sockaddr ) );
    server_sockaddr.sin_family = AF_INET;
    memcpy( &server_sockaddr.sin_addr, hp->h_addr_list[0], hp->h_length );
    server_sockaddr.sin_port = htons( wellKnownPort );
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    bind(  server_fd,
           (struct sockaddr *) &server_sockaddr,
            sizeof (server_sockaddr) );
    listen( server_fd, BACKLOG );
    socklen_t saSize = sizeof client_sockaddr;
    client_fd = accept( server_fd,
                    (struct sockaddr *) &client_sockaddr, &saSize );
This is "semi-pseudo" code because it is real code, but several things have been omitted: the proper header files, declaring most of the variables, and error checking. The gethostname(), gethostbyname(), socket(), bind(), listen(), and accept() calls should all be checked for success or failure, and the program should exit after calling perror() if any of them fail. (You test if hp is a null pointer to check for an error after calling gethostbyname(); all the other functions return an integer value of -1 if they fail.)

A way to test your server at this point is to use a Sample Client Java application. You can get copies of both the Java and class files from ~vickery/SampleClient.java and ~vickery/SampleClient.class on Spinoza. There is also an executable version of this version of the server available at ~vickery/bin/qserver-1.1 on spinoza if you would like to run it to make sure you understand what it is supposed to be doing.

At this point, your server should accept a connection from a client, write a message to its log file, and exit. Check the log file to make sure the connection message got written properly.

If your server encounters any errors before it makes its first call to accept(), it should write an error message to stderr and call exit() with a status code of 1 (the standard value to use when a program exits because of an error). Once it is ready to accept connections, it should open the log file, with the second argument to the openLog() function being a string in the form:

  qserver (<pid>) accepting connections at <host>:<port>
In this string, <pid> is to be the process id for the program, which you can get using the getpid(2) function. Substitute the computer's host name and the server's port number for <host> and <port> respectively.

You can use the following code to write connection messages to the log file:

    hp = gethostbyaddr( (const char *)&client_sockaddr.sin_addr, 4, AF_INET );
    writeLog( "CONNECT", hp->h_name );

Step 4. Handle SIGINT and Normal Exit

There are three ways to stop the server:
  1. If the server is running in the foreground, and you type Control-C in the server's console window. This will send an INT signal to the server.
  2. If you give the command "kill <pid>" substituting the server's actual process id for <pid>. This will send a TERM signal to the server.
  3. If one of the clients writes a "SHUT" message to the server.
In cases (1) and (2), the server is to call closeLog() with "Signal Received" as the argument. In case (3), the server is to call closeLog() with "Normal Exit" as the argument. The server doesn't actually exit until after closeLog() has run.

There are a number of ways you could meet these requirements, but I am specifying the technique you are to use for this assignment as follows:

Test your program to be sure that it exits cleanly, and with the proper message written to the logfile, no matter which of the three events is used to terminate the server.

Step 5. Reading From Clients

There are several functions that can be used to read and write to and from sockets, but the basic ones are read(2) and write(2). As kernel calls, these functions incur quite a bit of overhead compared to library functions, but using library functions with sockets adds its own problems to client/server communication. Two other kernel calls that may be used with sockets are recv(2) and send(2), which are just like read(2) and write(2), except that they have another argument that can be used to set certain options for more advanced operations. We'll stay with the simpler read and write functions for this project.

The idea of a socket connection is that it is a full-duplex (two-way) pipe connecting the client and server. Whatever the client writes to its end of the socket pair, the server can read from its end; likewise, whatever the server writes to its end, the client can read from its end. There is no way for the server to read what it has written, and there is no way for the client to read what it has written.

An important point to remember is that stream sockets are, as their name implies, used to send streams of bytes between clients and servers. For example, depending on network conditions, the server could receive a "SHUT" message with a single call to read() if the client executed either of the following two sequences of function calls:

      write( fd, "SHUT", 4 );
or
      write( fd, "S",  1  );
      write( fd, "HU", 2  );
      write( fd, "T",  1  );
We will deal with this issue in the next assignment, when we will adopt a protocol so that the server client and server will be able to identify the boundaries of the messages they send to each other. For this assignment, the server is simply to write whatever it reads to the log file, and to exit if a single call to read() returns exactly the four characters S-H-U-T.

But in this assignment, you do need to convert the bytes you read into strings so you can work with the text received using standard string functions. To do this, you need to append '\0' to the end of whatever characters are read from the socket:

      int numBytes = read( fd, buf, sizeof buf );
      buf[ numBytes ] = '\0';
(Some people have been zeroing out the buffer using memset() before each call to read(), which will work. But doing that is inefficient because it puts zeros in all the bytes of the buffer instead of just the one byte where it is needed.)

Once the server accepts a connection from a client, it enters an endless loop in which it reads from the socket, and writes to the logfile. It exits this loop when the client closes its end of the socket or when a client sends a SHUT message.

If the client closes its end of the socket (for example, by exiting), the server's call to read() will return a value of 0 (numBytes would be zero in the code above). The server must also close its end of the socket (using close()) before looping back to accept another client connection. If you fail to do this, the fd will remain open, and the server will eventually crash with too many open fds.

The Client

The client for this assignment is quite simple. It executes the following algorithm:
  1. Process command line options.
  2. Connect to the server.
  3. Repeat:
The client needs to recognize the following command line options:

-p | --port The server's port number to connect to.
-h | --host The server's host name.
-? | --help Print a usage message and exit.
-v | --version Print the client's version number and exit.

Here is semi-pseudocode for connecting to the server:

      hp = gethostbyname( serverName )
      server_sockaddr.sin_family = AF_INET;
      memcpy( &server_sockaddr.sin_addr, hp->h_addr, hp->h_length )
      server_sockaddr.sin_port = htons( serverPort )
      server_fd = socket( AF_INET, SOCK_STREAM, 0 )
      connect( server_fd, (struct sockaddr *) &server_sockaddr,
                                               sizeof server_sockaddr )
As with the semi-pseudocode provided for the server, you have to supply the variable declarations and add the code to check for errors.

Once the call to connect() completes successfully, you can use server_fd as the first argument in your call to write().

The client exits the loop at the end of its algorithm either when reaching the end of file on stdin or when the server closes its end of the socket. End of file on stdin will happen if the user types Control-D at the beginning of a line. You can test for it using feof(). The client won't know that the server has closed its end of the socket until it tries to write to it, which means that the user has to type something in to get to the second part of the read/write loop. The only reason the server should close its end of the socket before the client exits is if the user typed "SHUT," so you can make your client a little smarter by having it exit automatically if the user types "SHUT" (after first writing the message to the server!).

Final Testing and Submitting the Assignment

As always, be sure your code adheres to the Coding Guidelines for this course.

You need to test your code carefully. Be sure your Makefile is set up correctly by using the touch command to modify the modification times of the header, source, and object files and observing the commands that make generates in each case.

Be sure "make depend" and "make clean" are working correctly. Be sure that "make clean" followed by "make depend" works properly.

Be sure you have entered meaningful log messages each time you checked in your various files. (If not, the messages can be changed, but it's a lot of bother. See the -m option of the rcs command. Don't worry about minor typos.) Double-check that all RCS keywords have been expanded properly in your .h and .cc files as well as your Makefile.

You can check for yourself that there are no long lines or tab characters in your code on both spinoza or lnx0006. Give the following command:

      ~vickery/bin/chk_format Makefile *.h *.cc
There will be no output if your files are okay.

Be sure your code compiles and runs correctly on both spinoza and lnx0006. You should be able to run the server on either one and run clients on the other. Be sure you can run an endless sequence of clients, with the server exiting only when one of them sends a SHUT message or if one of the two signals is delivered to it.

Check the contents of your logfile to be sure it is formatted properly and contains the correct information. Be sure the -o command line option works properly for the server.

When your assignment is ready, clean your project directory, create a tar file of it, and email the tar file to me at my babbage account, vickery@babbage.cs.qc.edu.

The due date is midnight, April 16. If you don't have the project completed by then, send what you have done at that time so you can get partial credit. The assignment will count approximately 10% of your course grade.

Solution

Here are listings for my solution to this assignment: