Mini Lab: Networking

Assigned:
Friday, Mar 9, 2018
Due:
You do not need to submit any work for this mini lab.
Collaboration:
Work with your assigned partner for this lab. You can use online resources for your lab, provided they do not provide complete answers and you cite them in your code. If you do not know whether it is acceptable use a specific resource you can always ask me.

Overview

In today’s mini lab you will learn to use POSIX sockets to write programs that can communicate over a network. Networks is a large and important area of computer science that we cannot possibly cover in this class, but we’ll get a rough overview of one type of networking that plays a particularly important role in the internet: TCP/IP.

We’ll start by writing a simple server program that accepts incoming connections. At first, you will connect to this server using the telnet program, but later you will write a separate C program to connect to this server. By the end of the mini lab, you should have a very basic chat program that allows users to exchange messages in a somewhat constrained way.

Groups

Group information is no longer available for this course.

TCP/IP

TCP/IP, or Transmission Control Protocol over Internet Protocol implements a reliable communications channel that can be routed through the internet. We say that TCP/IP is reliable because it guarantees that messages will be delivered in order, or else you will learn of the failure at both endpoints. There are unreliable communications protocols (notably UDP) that do not offer this guarantee, in exchange for less overhead in setting up for communication.

The way we get guarantees with TCP/IP is by setting up connections. Every connection involves an initial set-up period between a client and server. We distinguish between clients and servers in TCP/IP because the server must listen for an incoming connection and accept it, while a client simply connects. Communication can proceed in both directions (client to server and server to client) once a connection has been set up, but the initial exchange always goes from client to server. This probably matches your sense of how browsing the web works; your browser is a client, which connects to various servers that are waiting to accept your incoming requests. Once you connect with a server you send the details of what you are requesting and possibly some other data (like your account credentials if the website asks you to log in) and the server sends back a webpage.

In addition to connections, there are two other important details you should think about before we move on: IP addresses and ports. An IP address is a four-byte identifier that uniquely identifies your computer within a network. This address, usually written as four one-byte numbers separated by dots (e.g. “127.0.0.1”), is like the mailing address on each message (called a packet) sent over a connection. In addition to a destination address, messages and connections have a return address, which servers use to decide where to send responses after receiving requests.

Along with an address, we also need to know which port a connection is set up to use. A server listens for incoming connections on a specific port, and only connections to this port will reach the server. Ports are just integers, but some ports have specific meanings; port 80 carries HTTP (web) traffic, port 21 is used for ssh connections, and a many other low-numbered ports are associated with specific types of traffic. Higher-numbered ports (with four or more digits) are typically available for whatever use you like.

It’s not that this traffic could not go over a different port, but specific types of connections have default ports. The one you are likely most-familiar with is HTTP (HyperText Transfer Protocol), which operates on port 80. The secure version of HTTP, called HTTPS, operates on port 443. When you write a web address like https://google.com you are implicitly telling your browser to connect to the server with the name google.com on port 443. You could instead specify a port, as in the address https://google.com:999, but this connection will not work because Google does not listen for incoming connections on port 999.

You may have noticed that we wrote google.com as an address rather than an IP address; translating human-readable names to IP address is the job of DNS (Dynamic Name Service), an interesting and critically important distributed system. We won’t go into the details of DNS today, but this topic may make an appearance in later classes.

Writing a TCP Server

We’ll start out our work with networks by writing a simple TCP server that can accept a single connection. To do this, we’ll create a server socket, which is one endpoint of a TCP connection. The server socket is used to accept incoming connections, which are then split off into a new socket on the server end, leaving the server socket free to accept additional incoming connections.

You should write your server in the main function of a new C source file. You’ll need a number of include files for this example:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

The first step in setting up a server is to create a socket. To do this, we call the socket function in your program’s main function:

int s = socket(AF_INET, SOCK_STREAM, 0);

This line of code sets up an internet socket using TCP (that’s what SOCK_STREAM means) with no special protocol options. The return value from socket is -1 if anything goes wrong, or the file descriptor for the new socket. File descriptors are just numbers that uniquely identify an open file, and we’ll use it to refer to this socket in the future.

You should write error checking code whenever possible, but it’s particularly important with sockets because so many things can go wrong:

if(s == -1) {
  perror("socket failed");
  exit(2);
}

The next step in setting up our server is to tell it where it should listen for incoming connections. We’ll do this by filling in a struct sockaddr_in, which is short for an internet socket address.

struct sockaddr_in addr;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_family = AF_INET;
addr.sin_port = htons(4444);

These lines set up a struct that listens for any incoming address using an internet socket (as opposed to a local socket or one for a more unusual network system), and listens on port 4444. The htons function is one of a family of functions that deal with byte order.

Byte Ordering

Any time a value spans multiple bytes we have a choice to make about how to store that value. Consider the number 1234, which is 0x04D2 in hex or 0000010011010010 in binary. This number spans two bytes (16 bits). Which order should those two bits appear in? If we use the first byte (the one with the lower-numbered address) to hold the lower-order byte (0xD2, or 11010010) we are using a little endian representation. If we instead store the higher-order byte first, we have a big endian representation. The choice of representation isn’t particularly important, but most processor architectures have a particular endianness. Protocols that connect machines of different types have to deal with this, usually by specifying a particular endianness. Our machines use the x86-64 architecture, which is little-endian, while TCP is big endian. To convert numbers between these representations we just reorder some bytes, but this can be a bit of a hassle. The htons function is short for host to network (endianness) for a short. This function will do the appropriate byte reordering for the host machine to send data in the network protocol’s required endianness.

Designated Initializers

You may have noticed that packing values into structs to prepare parameters for POSIX calls is a common operation in C. This can get a bit tedious, particularly if your struct variables have long, descriptive names. To avoid this issue, the C programming language has a feature known as designated initializers. These allow you to fill in multiple fields of a struct all in one statement. The example code above could be rewritten to use designated initializers like this:

struct sockaddr_in addr = {
  .sin_addr.s_addr = INADDR_ANY,
  .sin_family = AF_INET,
  .sin_port = htons(4444)
};

One great feature of designated initializers is that any unspecified struct fields are initialized to zero; that’s different from the original code, where an uninitialized field is left with whatever the memory holding the struct contained before this code ran.

Now that we have a socket and an address, we can bind the socket to that address, effectively telling it what address to listen from.

if(bind(s, (struct sockaddr*)&addr, sizeof(struct sockaddr_in))) {
  perror("bind failed");
  exit(2);
}

This line binds socket s to address addr. This is a bit complicated with a cast and size parameter because there are different types of sockets; we are required to cast a pointer to our struct sockaddr_in to a pointer to a struct sockaddr, the generic form of a socket address and let bind figure out what to do with the rest of the struct.

Now that we’ve bound our socket to a specific address (where it should listen), we can begin listening.

if(listen(s, 2)) {
  perror("listen failed");
  exit(2);
}

This call tells the socket to begin listening for incoming connections, with up to two pending connections at a time. If more than two clients attempt to start connections at once the socket will reject them.

Finally, now that we are listening, we can accept an incoming connection. The accept function blocks until a connection is established, and it returns the file descriptor for a new socket we can use to talk directly with that specific client. The server socket we set up at first is still active and listening, but can only be used to accept new connections, not to communicate directly with any particular client. Part of the process of setting up a connection with a new client is to write out the address of the client so we can use it later, which complicates this code a bit.

struct sockaddr_in client_addr;
socklen_t client_addr_length = sizeof(struct sockaddr_in);
int client_socket = accept(s, (struct sockaddr*)&client_addr, &client_addr_length);

if(client_socket == -1) {
  perror("accept failed");
  exit(2);
}

Assuming we do not hit the error case, this code will set up a new socket for us to communicate with our newly-connected client. Now that we have this socket set up, we can use read and write to interact with the client.

char* msg = "Hello client.\n";
write(client_socket, msg, strlen(msg));

This line just writes a string to the socket. That’s all we’re going to do for now, but we should close the client and server sockets before exiting.

close(client_socket);
close(s);

Testing your Server

Once you’ve collected all this code in your main function, compile it as usual and run the program. It should just sit there waiting for incoming connections. We’ll connect with telnet to start. In your shell, run the command:

$ telnet localhost 4444

You should see your message printed out (along with some other output), followed by the program exiting.

Writing a Client

Our client program will be in a separate C source file, with its own main function. You’ll need all the same include files as before, with one addition:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>

The client program will take a command line option, which specifies the address of the server to connect to. If you’re running the client and server on the same machine you can use the name “localhost”. However, you can also connect across machines (that’s the point of a network, isn’t it?) by using the name of a machine with “.cs.grinnell.edu” at the end. For example, if your server is running on turing.cs.grinnell.edu and your client runs on baran.cs.grinnell.edu, the client would connect to turing.cs.grinnell.edu.

Before we can connect to a server, we have to turn the human-readable name into an IP address. While it’s possible to do this manually and specify an IP address our program, it’s easier to have the system do this conversion for us.

struct hostent* server = gethostbyname(argv[1]);
if(server == NULL) {
  fprintf(stderr, "Unable to find host %s\n", argv[1]);
  exit(1);
}

Now we can set up a socket as before:

int s = socket(AF_INET, SOCK_STREAM, 0);
if(s == -1) {
  perror("socket failed");
  exit(2);
}

We’ll initialize a socket address in a similar way as well.

struct sockaddr_in addr = {
  .sin_family = AF_INET,
  .sin_port = htons(4444)
};

Note that this did not specify an address. We’ll fill that in from the server variable we set up earlier. To do this, we just copy some bytes with the bcopy function.

bcopy((char*)server->h_addr, (char*)&addr.sin_addr.s_addr, server->h_length);

This just copies over the address from server into the struct sockaddr_in. Now that we have a socket and an address, we can connect to the server with the connect function.

if(connect(s, (struct sockaddr*)&addr, sizeof(struct sockaddr_in))) {
  perror("connect failed");
  exit(2);
}

Assuming everything goes smoothly at this point, you can read and write the socket to communicate with the server. Our server writes to the socket, so our client should probably read at this point. We’ll use a fixed-size buffer to read some input from the server.

char buffer[256];
int bytes_read = read(s, buffer, 256);
if(bytes_read < 0) {
  perror("read failed");
  exit(2);
}

printf("Server sent: %s\n", buffer);

Once you’ve collected this code in your client’s main function, compile it as usual. Start your server, then start the client, with localhost as the command line option. You should see the server’s output on the client side.

Finally, close the client’s socket before you exit.

close(s);

Once you have a working client and server, move on to the problems below.

Problems

Depending on how long it takes to get a working client and server, you may not be able to get through each of the following problems. Do your best, but don’t worry too much if you don’t have a chance to do everything.

Problem 1: Echo Server

One of the first client–server programs most CS students write is an echo server. This system simply reads input from the user in the client program (using fgets or something similar) and sends it to the server. The server then sends this exact message back. The client waits for this response and displays it.

You won’t know how long an incoming message from a client is, so your server should probably read one character at a time until it hits a newline character. You can assume an upper limit on the size of the client’s message (say 256 bytes) so you don’t have to dynamically expand the array for incoming messages.

Problem 2: Capitalization Server

Now that you have a program that sends messages from the client to the server and back, you can do something more interesting on the server end. Instead of sending back the original message, have the server send back a capitalized version of the message. You can convert a character to its capitalized version with the toupper function.

Problem 3: Two-way Chat

Your server program only accepts one client at the moment, but you can call accept multiple times to connect with more than one client. Modify your server to connect with two clients by calling accept twice. Your server will mediate a conversation between these two clients by reading from client 1 and sending this message to client 2. Then, the server will read from client 2 and send to client 1.

You will probably need to change your client program for this challenge. If you set the client up to listen for a message at startup, the server could send the message “Type your message” to the first client. That way the first client will be in sending mode (once the user types some input) and the second client will be listening to the server. After the first message is sent the two clients will swap roles, just as the server expects. Be careful to make sure you don’t end up with both the server and client waiting to read at the same time when there is no data to read at either end of the TCP connection; this case will cause both programs to block.

Problem 4: Parallel Chat

We often use threads in servers so multiple clients can interact with the server at the same time. When your server accepts a new connection, start a new thread to read from that client. Any time a thread reads a message from a client, send that message back to all the other clients. You will need a data structure to hold all the client sockets so each thread can write to them, and you should use a lock to make sure multiple clients do not write to the same socket at the same time. You don’t need to support more than two clients for this problem, but it probably won’t be much more work to handle an arbitrary number of clients if you are careful about how you design your solution.

You should also add threads to the client. When the client connects to the server it should create one thread to read input from the user and send it to the server, and another thread to read input from the server and display it. Even if this works correctly you may see some strange behavior as messages from the server appear in the middle of what a client is typing; addressing this requires a more interesting user interface, but focus on the basic functionality for now.