Networking Project in C - Chat Room

In this tutorial, we will explore how to create a chat room using the C programming language. We will start by implementing the server, which will allow multiple clients to connect and exchange messages in real time. By following this step-by-step guide, you will gain a better understanding of networking in C and learn how to build a simple chat application.

Full code can be found at https://github.com/lets-learn-it/c-learning/tree/master/16-networking/02-chat-room-PROJECT

Setting up server

The server will handle client connections, message reception, and message broadcasting.

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <string.h>
#include <sys/time.h>
#include <errno.h>

#define MAX_CLIENTS 4

int main(int argc, char const *argv[]) {
  int mastersockfd, connfds[MAX_CLIENTS];
  int activeconnections = 0;

  // Rest of the code...
}

The code starts by including necessary header files and defining the maximum number of clients (MAX_CLIENTS) that can connect to the server. We are initializing mastersockfd which will listen for new connections and connfds is an array of all live TCP connections.

We will create a socket using the AF_INET address family (IPv4) and SOCK_STREAM protocol (TCP). It returns a file descriptor for the socket. If the socket creation fails, an error message is printed, and the program exits.

if((mastersockfd = socket(AF_INET, SOCK_STREAM, 0)) <= 0) {
  perror("error while creating socket...");
  exit(1);
}

Next, we configure the server's address using the sockaddr_in structure. We set the address family, and port number (8081 in this example), and bind it to any available network interface (INADDR_ANY).

serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8081);
serv_addr.sin_addr.s_addr = INADDR_ANY;

To ensure that the socket can be reused immediately after the server is stopped, we set the SO_REUSEADDR option to use the setsockopt function. More can be found on https://stackoverflow.com/questions/2208581/socket-listen-doesnt-unbind-in-c-under-linux

int opt = 1;
setsockopt(mastersockfd, SOL_SOCKET, SO_REUSEADDR, (void *) &opt, sizeof(opt));

The bind function binds the socket to the server's address. If the binding fails, an error message is printed, and the program exits.

if(bind(mastersockfd, (struct sockaddr *) &serv_addr, addrlen) < 0) {
  perror("bind failed...");
  exit(1);
}

We use the listen function to listen for incoming client connections on the socket. The second argument (3 in this case) specifies the maximum number of pending connections that can be queued.

if(listen(mastersockfd, 3) < 0) {
  perror("Listen failed ...");
  exit(1);
}

Asynchronous I/O Multiplexing

As we will be dealing with multiple clients at the same time, How can we know which connection is ready with new data? We want to be notified when the connection is ready with new data or ready for reading. This capability is called I/O multiplexing. and is provided by select and poll functions. More on this can be read at https://notes.shichao.io/unp/ch6/.

We will be using select for working with asynchronous I/O multiplexing.  To begin with, we need to define the necessary variables. The fd_set structure is used to hold the file descriptors. We declare readfds as an fd_set to keep track of the file descriptors available for reading. We also define variables max_fd and readyfds.

fd_set readfds;
int max_fd, readyfds;

In infinite while loop, we will continuously check for incoming connections and messages from clients. At the start of each iteration, we clear the readfds set using FD_ZERO to remove any previously set file descriptors. Next, we add the master socket (mastersockfd) to the readfds set using FD_SET. The master socket is the socket that listens for new client connections. We also update the max_fd variable with the maximum file descriptor value. We iterate through the array of active client sockets (connfds) and add each valid socket to the readfds set using FD_SET. We also update the max_fd value if necessary.

FD_ZERO(&readfds);

// add mastersockfd
FD_SET(mastersockfd, &readfds);
max_fd =  mastersockfd;

for(int i=0;i < activeconnections;i++) {
  if(connfds[i] != 0)
  	FD_SET(connfds[i], &readfds);
  if(connfds[i] > max_fd)
  	max_fd = connfds[i];
}

We call the select function to wait for activity on the file descriptors. It monitors the file descriptors specified in the readfds set and blocks until any of them become available for reading. The max_fd + 1 parameter ensures that select checks all file descriptors from 0 to max_fd. If the select call returns a negative value (readyfds < 0) and the error is not due to an interrupted system call (errno != EINTR), an error message is displayed.

readyfds = select(max_fd + 1, &readfds, NULL, NULL, NULL);

if ((readyfds < 0) && (errno!=EINTR)) {
  printf("select error");
}

We check if the master socket is part of the readfds set using FD_ISSET. If so, it means a new client is attempting to connect. We accept the connection using the accept function and store the new socket descriptor in the connfds array. We also print the client's IP address using inet_ntoa and increment the activeconnections counter.

if(FD_ISSET(mastersockfd, &readfds)) {
  if((connfds[activeconnections] = accept(mastersockfd, (struct sockaddr *) &clientIPs[activeconnections], (socklen_t *) &addrlen)) < 0) {
    perror("accept error...");
    exit(1);
}

  fprintf(stdout, "New connection from %s\n",  inet_ntoa(clientIPs[activeconnections].sin_addr));
  activeconnections++;
}

For each active client socket, we check if it is part of the readfds set using FD_ISSET. If the socket is ready for reading, we clear the input and output buffers using memset. We read data from the client using the read function. If the return value is 0, it means the connection was closed normally, and if it is -1, an error occurred. We handle these cases by printing an error message, marking the connection as closed, and closing the socket. We then continue to the next iteration of the loop.

If the read operation is successful, we retrieve the client's IP address from clientIPs using inet_ntoa and store it in the output buffer. We also print the client's IP address and the received message to the console. We concatenate the client's IP address and the message into the output buffer. Then, we iterate through all active connections and write the message to each client except the sender, using the write function.

for(int i=0;i < activeconnections; i++) {
  // check if connection is active and it is ready to read
  if(connfds[i] != 0 && FD_ISSET(connfds[i], &readfds)) {
    // clear buffer
    memset(inBuffer[i], 0, 1024);
    memset(outBuffer[i], 0, 1024);

    // read returns 0 if connection closed normally
    // and -1 if error
    if(read(connfds[i], inBuffer[i], 1024) <= 0) {
      fprintf(stderr, "%s (code: %d)\n", strerror(errno), errno);
      strncpy(outBuffer[i], inet_ntoa(clientIPs[i].sin_addr), INET_ADDRSTRLEN);
      fprintf(stderr, "Host %s disconnected\n", outBuffer[i]);
      close(connfds[i]);
      connfds[i] = 0;
      continue;
    }

    // get client ip
    strncpy(outBuffer[i], inet_ntoa(clientIPs[i].sin_addr), INET_ADDRSTRLEN);

    fprintf(stdout, "%s: %s", outBuffer[i], inBuffer[i]);

    strcat(outBuffer[i], " : ");
    strcat(outBuffer[i], inBuffer[i]);

    for(int j=0;j<activeconnections;j++) {
       if(connfds[j] != 0 && i != j) {
         write(connfds[j], outBuffer[i], strlen(outBuffer[i]));
       }
    }
  }
}

Setting up client

Firstly, we define a function called readline that reads input from the user until a specified end-of-character is encountered. This function will be used to read input from the user and send it to the server.

int readline(char *buffer, int maxchars, char eoc) {
  int n = 0;
  while(n < maxchars) {
    buffer[n] = getc(stdin);
    if(buffer[n] == eoc)
      break;
    n++;
  }
  return n;
}

Moving on to the main function, we declare some variables and initialize them. sockfd represents the socket file descriptor, serv_addr is a structure that holds the server's address information, and sendline and recvline are character arrays to store the messages to be sent and received, respectively.

int sockfd;
struct sockaddr_in serv_addr;
char sendline[1024], recvline[1024];

We set up the server address by assigning the address family (AF_INET) and the port number (8081) to the serv_addr structure. Additionally, we convert the server IP address from a string format to binary using the inet_pton function. If the conversion fails, an error message is displayed, and the program exits.

serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8081);

if(inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr)<=0) { 
  perror("addre");
  exit(-1);
} 

To establish a connection with the server, we use the connect function. It takes the socket file descriptor (sockfd), the server address structure, and its size as arguments. If the connection fails, an error message is printed, and the program exits.

if(connect(sockfd, (struct sockaddr *) &serv_addr, sizeof serv_addr) < 0) {
  perror("connect error...");
  exit(1);
}

We set up a loop that continuously listens for input from the user and messages from the server. The fd_set data structure is used to monitor the file descriptors for activity. In our case, we monitor the user input (0) and the socket (sockfd) for readability.

Within the loop, we call select to check for any active file descriptors. If select returns a value less than 0 and the error is not due to interruption (EINTR), an error message is printed.

If the user input (stdin) is ready for reading, we call the readline function to read the input from the user and store it in the sendline buffer. Then, we use the write function to send the input to the server.

If the socket is ready for reading, we use the read function to receive data from the server and store it in the recvline buffer. Finally, we print the received data to the standard output using fprintf.

fd_set waitfds;
int readyfds;
while(1) {
  FD_ZERO(&waitfds);

  // add mastersockfd
  FD_SET(sockfd, &waitfds);
  FD_SET(0, &waitfds);

  memset(recvline, 0, 1024);
  memset(sendline, 0, 1024);

  // connfd will be always largest
  readyfds = select(sockfd + 1, &waitfds, NULL, NULL, NULL);
  if ((readyfds < 0) && (errno!=EINTR)) {
    printf("select error");
  }

  // if stdin ready, read it and send
  if(FD_ISSET(0, &waitfds)) {
    readline(sendline, 1024, '\n');
    write(sockfd, sendline, strlen(sendline));
  }
    
  // if socket ready, read it and print
  if(FD_ISSET(sockfd, &waitfds)) {
    read(sockfd, recvline, 1024);
    fprintf(stdout, "%s", recvline);
  }
}