Creating a simple router simulation using Python and sockets

Nimish Mishra
The Startup
Published in
18 min readDec 10, 2019
Let’s build a computer network!

Introduction

Back in the days when networking technology was still young, any data you sent was delivered to everybody on the network, and the one true recipient recognised the data was meant for them and accepted it, while the others simply discarded it. Now you may wonder, and correctly so, that such a scheme in modern environment of mistrust is simply flawed. You can’t send your two-way communication data to everyone on the network.

Technically, the above scheme was in the era of hubs, which were non-intelligent devices routing traffic to everyone. As networking technology grew more sophisticated, intelligent devices came with the ability to identify the correct recipient and route traffic accordingly. We’re talking about switches and routers, which currently usually come as a single device.

In this article, we’ll be developing a very simple router simulation in Python, simulating a very simple network with a single server and multiple clients. The server shall be sending some data to the router, and the router will have functionality to decide which client to deliver the data to.

Pre-requisites

  1. Some experience working with Python
  2. Basic idea about nodes, clients, and servers. And the fact that there may be several server applications and several client applications on a node.
  3. Some idea about MAC addresses and IP addresses, mainly the fact how your computer has a globally unique MAC address but it’s IP address is dependent on the network it is connected to, currently.

How modern networks usually work?

Specifically what we need to understand to begin working?

MAC addresses are globally unique addresses. How exactly these are assigned is a topic for deep discussion, but it suffices to know that MAC addresses are fixed for a certain node, irrespective of the network the node is connected to.

Now imagine a model of networking where there are only MAC addresses. You have a node with MAC address AA:AA:AA:AA:AA:AA and your friend has a node with MAC address BB:BB:BB:BB:BB:BB. You wish to send some data to your friend. Now there are millions of nodes in the world, comprising small networks which combine to form the all-pervading internet. Which of these smaller networks your friend’s MAC address is in?

It turns out a networking model with MAC address is not scalable. This is because MAC addresses belong to nodes, and you have no way to determine which network in the world your MAC may be in, i.e. BB:BB:BB:BB:BB:BB has no way of telling you where in the world this node is currently. Delivering data thus implies your router needs to scan the entire world’s networks to determine which network has this MAC address connected.

And thus comes IP addressing mode. While MAC addresses are properties of specific nodes, IP addresses are properties of networks (IP addresses assigned to nodes depend on the network the node is connected to). This means given one IP address, say 9.100.100.8, there are methods to determine which network of the world this IP belongs to.

For instance, your friend’s node may have the IP address 9.100.100.12, and this may be the network of some MNC based in Europe. This makes searching for the destination node easier than searching in the MAC address model of networking discussed above. Your router will now route traffic to the specified network in Europe, and routers in Europe shall further narrow down the geographical location of your friend’s node, until they come to a switch to which your friend is directly connected to. This switch shall then directly send data to your friend, based on their MAC address.

To keep our discussion simple, I’ll consider the switch and the router as a single device (which modern networks use). The following happens when you send data to your friend who may be in any part of the world:

  1. Router X directly connected to you receives the data.
  2. Router X now determines the IP address of the destination.
  3. If Router X is not directly connected to the destination, it considers the shortest path to the destination, and proceeds the data to another Router Y. Router Y does the same thing and proceeds the data to another Router Z. The process goes on and on until we reach the router to which the destination node is directly connected to.
  4. After step 3, it is guaranteed the router currently having the data is directly connected to the destination, i.e. the destination node is within the network spanned by the current router. Let’s call the current router as Router A.
  5. This Router A has a table, called the ARP table (address resolution protocol table), which maps all the IP addresses in the router’s network to all the MAC addresses of the nodes connected in the router’s network. Why? Recall that IP addresses are network driven, so today Laptop A may have the IP address 9.100.100.12 and tomorrow Laptop B may have the same IP address, implying IP addresses aren’t unique. What is unique are the MAC addresses. Thus to truly determine the state of a network, the router stores a mapping that maps all the IP addresses which the router recognises as its network, to the respective MAC addresses of the nodes currently connected to the router. Thus, Router A has one such mapping 9.100.100.12 → BB:BB:BB:BB:BB:BB. Whenever Router A receives data meant for 9.100.100.12, it knows it needs to send the data to the MAC address BB:BB:BB:BB:BB:BB.
  6. Done

Briefly, IP addresses are used to send data between different networks, while MAC addresses are used to send data within a network.

This also explains the existence of smaller networks in the world. Recall that the MAC address model of networking determined the destination based on the MAC address, and it was not scalable.

So to build the big network, you try to scale the MAC address model of networking to as many nodes as you possibly can, and you call these sub-networks. Now when continuing with the MAC address model of networking becomes infeasible, you connect these sub-networks using the IP addressing mode and create a wider, bigger network.

Creating a simple server-client application using Sockets

To keep things simple, we’ll be creating a very simple server client application on the same machine, i.e. the same node. This should also reinforce the idea that usually there are several server applications and client applications running on the same node.

Creating a server

A very basic skeleton of a simple server

The first statement serves to create a socket that we’ll use to establish connections. socket.AF_INET serves to open a socket of the most common IPv4 protocol (IP version 4); here AF stands for Address Family and INET stands for Internet. The second argument defines the type of socket opened.

bind((‘host name’ , ‘port number’ )) serves to bind the socket. You may think of this as hosting the socket on the machine, like an address of the socket. This takes a tuple containing the host-name and the port number. Keep the port number any 4 digits, lower digit numbers are reserved for standard protocols.

listen(number-of-queue-members) implies this socket takes connection requests from other sockets. This is how you define a server. You want the server to listen for clients trying to connect to the server, accept their connection, and send some data. number-of-queue-members serves to identify the number of connection requests you wish to be in the queue. Should the server be loaded with heavy traffic, you can define the size of a queue, in order to accept those number of connection requests, and discard others.

accept() accepts a connection request. Note that while listen() implies this socket is on the pry listening for connections, accept() actually identifies a connection request and returns the socket object and the address of the socket trying to connect. It may be intuitive now while we need to embed the accept() function within an infinite loop: we don’t know when the connection request shall be made, so open the server listening for connections and let it keep listening. When some connection request is made, capture that.

Creating a client

The second statement, connect() is the counter-part of listen(). By connect() you are trying to tell this socket to try connect to the socket (socket.gethostname(), 8000), which is the server we had defined earlier. So, we had a server of name socket.gethostname() sitting on port number 8000, and we tell our client socket to connect to it. Note when this connect statement executes, a connection request is sent to the server, and it must be captured by the accept() function defined in the server. It would be intuitive now that we must run the server.py before we run the client.py, exchanging that order would simply throw an error corresponding to the fact that there is no socket sitting on port 8000 listening to requests from other sockets.

recv(1024) serves to receive any data the server might send. Again, the client has to wait after making the connection. The server might not send data straightaway. The client’s connection may be in a queue, or simply the server may be processing some other things before sending the data. Hence, it is intuitive to encapsulate this function within an infinite loop, to receive data whenever it comes. The number 1024 is the buffer size in bytes, the maximum data that can be received in one packet sent by the server.

In modern networking, there may be several kilo-bytes of data, so the server needs to send fragments of data. We’ll, however, not worry about that as yet.

Sending and receiving data

Here is the same server sending some message after a client has established connection:

After the connection from the client has been established, send(bytes(‘message’, ‘encoding’)) serves to actually send some data to the connected client. bytes() serves to convert the message into utf-8 encoded byte stream which is then sent across the network to the destination client.

Here is the same client modified to receive the data sent above:

A simple decode(‘encoding’) function to decode the received data, and print what is received.

Building a simple router application

A router shall serve to let multiple clients connect to it, and direct a packet from the server to one on the clients connected.

Before moving further, I’ll pause and discuss a bit about headers. Routers decide where to route traffic based on the headers, which are extra information over original message that is used by routers to successfully deliver packets to the destination.

Give a look to the standard IP header:

Source: https://nmap.org/book/tcpip-ref.html

This contains loads of information, like Version signifying whether this is IPv4 or IPv6 protocol, fragment flags and offsets signifying whether this received packet is actually a part of a bigger message and the destination needs to reconstruct the original message from a series of packets, header checksum signifying some error correction codes, and so on.

For our application, we’ll consider a very simple IP header, containing only the Source Address (Source IP address) and the Destination Address (Destination IP address). Likewise, there is another header, called the Ethernet header (encapsulating information about the MAC addresses).

So in our application, we’ll construct a packet as follows:

Packet = Source MAC — Destination MAC — Source IP — Destination IP — Message

Also, for sake of simulation, I have given arbitrary MAC addresses and IP addresses to different clients and servers, since I am running the entire network on a single node. If you may, you can have different nodes functioning either as a client or a server, and construct your network accordingly.

The easiest is to define the clients first — receive some packet from the socket you call the router, dissect that packet based on packet structure discussed above, and display contents.

Client 1

Below is the code for client 1.

import socket
import time
client1_ip = "92.10.10.15"
client1_mac = "32:04:0A:EF:19:CF"
router = ("localhost", 8200)client1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)time.sleep(1)
client1.connect(router)
while True:
received_message = client1.recv(1024)
received_message = received_message.decode("utf-8") source_mac = received_message[0:17] destination_mac = received_message[17:34] source_ip = received_message[34:45] destination_ip = received_message[45:56] message = received_message[56:] print("\nPacket integrity:\ndestination MAC address matches client 1 MAC address: {mac}".format(mac=(client1_mac == destination_mac))) print("\ndestination IP address matches client 1 IP address: {mac}".format(mac=(client1_ip == destination_ip))) print("\nThe packed received:\n Source MAC address: {source_mac}, Destination MAC address: {destination_mac}".format(source_mac=source_mac, destination_mac=destination_mac))

print("\nSource IP address: {source_ip}, Destination IP address: {destination_ip}".format(source_ip=source_ip, destination_ip=destination_ip))

print("\nMessage: " + message)

To define this client, I have set up a client1_ip and client1_mac, defined a router socket specification (which will be detailed in the code for the router) and connected this client to the router. Then this client received the message, decoded it, and dissected the packet recieved.

In our simple application concerning functionality of a router, I have used strings for IP and MAC addresses. Generally, you would see 32 bits and 48 bits respectively, but since here I have just included the numbers manually in strings, IP and MAC addresses are indexed character wise (or their size equals the number of characters they have in the string representation). Thus there are 17 characters in a MAC address (including colons) and 11 in a IP address (including dots).

Once the packet is dissected, we perform a packet integrity test, if the packet was truly meant for this client, by client1_mac==destination_mac and client1_ip==destination_ip. If these are true, your router has performed well.

I create two other clients on similar lines:

Client 2

import socket
import time
client2_ip = "92.10.10.20"
client2_mac = "10:AF:CB:EF:19:CF"
router = ("localhost", 8200)client2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)time.sleep(1)
client2.connect(router)
while True:
received_message = client2.recv(1024)
received_message = received_message.decode("utf-8") source_mac = received_message[0:17] destination_mac = received_message[17:34] source_ip = received_message[34:45] destination_ip = received_message[45:56] message = received_message[56:] print("\nPacket integrity:\ndestination MAC address matches client 2 MAC address: {mac}".format(mac=(client2_mac == destination_mac))) print("\ndestination IP address matches client 2 IP address: {mac}".format(mac=(client2_ip == destination_ip))) print("\nThe packed received:\n Source MAC address: {source_mac}, Destination MAC address: {destination_mac}".format(source_mac=source_mac, destination_mac=destination_mac)) print("\nSource IP address: {source_ip}, Destination IP address: {destination_ip}".format(source_ip=source_ip, destination_ip=destination_ip))
print("\nMessage: " + message)

Client 3

import socket
import time
client3_ip = "92.10.10.25"
client3_mac = "AF:04:67:EF:19:DA"
router = ("localhost", 8200)
client3 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
time.sleep(1)
client3.connect(router)
while True:
received_message = client3.recv(1024)
received_message = received_message.decode("utf-8") source_mac = received_message[0:17] destination_mac = received_message[17:34] source_ip = received_message[34:45] destination_ip = received_message[45:56] message = received_message[56:] print("\nPacket integrity:\ndestination MAC address matches client 3 MAC address: {mac}".format(mac=(client3_mac == destination_mac)))

print("\ndestination IP address matches client 3 IP address: {mac}".format(mac=(client3_ip == destination_ip)))

print("\nThe packed received:\n Source MAC address: {source_mac}, Destination MAC address: {destination_mac}".format(source_mac=source_mac, destination_mac=destination_mac))
print("\nSource IP address: {source_ip}, Destination IP address: {destination_ip}".format(source_ip=source_ip, destination_ip=destination_ip)) print("\nMessage: " + message)

Now that we have defined the clients, it is time to define the server.

Server

The server functions as follows (based on the socket concepts discussed above when we created a simple server and a client):

  1. Server receives a connection request from the router
  2. Server creates a new packet
  3. Server sends a packet to the router

We’ll do this step by step. First let’s allow the server to receive a connection request from the router.

import socketserver = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("localhost", 8000))
server.listen(2)
server_ip = "92.10.10.10"
server_mac = "00:00:0A:BB:28:FC"
router_mac = "05:10:0A:CB:24:EF"while True:
routerConnection, address = server.accept()
if(routerConnection != None):
print(routerConnection)
break

Fairly simple, I bind a new server socket by localhost on the port 8000 (remember we have decided the router should be on 8200, as coded in our clients). I give the server an arbitrary IP address and a MAC address.

The server also knows the MAC address of the router. Why? Because this server is directly connected to the router (or this server belongs to the network spanned by the router at port 8200; still remember we are doing everything on a single node). So this server knows the destination MAC address, which is the MAC address of the router.

The while loop serves to accept a connection from the router and accordingly exit when a connection has been established.

Now we create a new packet to send to the server. Recall from the discussion on the structure of the packet that we need to do something like:

Packet = Source MAC — Destination MAC — Source IP — Destination IP — Message

We have the source MAC and source IP (that of the server) and the destination MAC (that of the router). The destination IP, however, is of the node you are sending the packet to. In our case, it will be one of the three clients we have set up.

    ethernet_header = ""
IP_header = ""

message = input("\nEnter the text message to send: ")
destination_ip = input("Enter the IP of the client to send the message to:\n1. 92.10.10.15\n2. 92.10.10.20\n3. 92.10.10.25\n") if(destination_ip == "92.10.10.15" or destination_ip == "92.10.10.20" or destination_ip == "92.10.10.25"):

source_ip = server_ip
IP_header = IP_header + source_ip + destination_ip

source_mac = server_mac
destination_mac = router_mac
ethernet_header = ethernet_header + source_mac + destination_mac

packet = ethernet_header + IP_header + message

Here, for sake of simplicity, I ask the user to input some message and the destination IP of the client to send that message to. Note how the packet is generated. In general terms, this is the process of packet generation, just on a more complex level where you will have to create the packets as determined by the headers of the protocols being used.

Send this packet to the router:

    ethernet_header = ""
IP_header = ""

message = input("\nEnter the text message to send: ")
destination_ip = input("Enter the IP of the clients to send the message to:\n1. 92.10.10.15\n2. 92.10.10.20\n3. 92.10.10.25\n") if(destination_ip == "92.10.10.15" or destination_ip == "92.10.10.20" or destination_ip == "92.10.10.25"):
source_ip = server_ip
IP_header = IP_header + source_ip + destination_ip

source_mac = server_mac
destination_mac = router_mac
ethernet_header = ethernet_header + source_mac + destination_mac

packet = ethernet_header + IP_header + message

routerConnection.send(bytes(packet, "utf-8"))

else:
print("Wrong client IP inputted")

Here, routerConnection is the socket object corresponding to the router connection accepted in step 1 of designing the server.

The entire code as one:

import socketserver = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("localhost", 8000))
server.listen(2)
server_ip = "92.10.10.10"
server_mac = "00:00:0A:BB:28:FC"
router_mac = "05:10:0A:CB:24:EF"while True:
routerConnection, address = server.accept()
if(routerConnection != None):
print(routerConnection)
break
while True:
ethernet_header = ""
IP_header = ""

message = input("\nEnter the text message to send: ")
destination_ip = input("Enter the IP of the clients to send the message to:\n1. 92.10.10.15\n2. 92.10.10.20\n3. 92.10.10.25\n") if(destination_ip == "92.10.10.15" or destination_ip == "92.10.10.20" or destination_ip == "92.10.10.25"):
source_ip = server_ip
IP_header = IP_header + source_ip + destination_ip

source_mac = server_mac
destination_mac = router_mac
ethernet_header = ethernet_header + source_mac + destination_mac

packet = ethernet_header + IP_header + message

routerConnection.send(bytes(packet, "utf-8"))
else:
print("Wrong client IP inputted")

Now to build the router application.

Router

Now we need to think of how the router must be done. Server → Router → Client 1/2/3.

The router works as this:

  1. Let the clients come online. You do not want to start accepting packets from the server unless the clients are online.
  2. Establish a connection to the server and receive a very simple packet from it.
  3. Strip the ethernet header from the received packet and create a new one. Why? Because the original ethernet header had the source MAC = server MAC and destination MAC = router MAC; now things are changed. The source MAC = router MAC and destination MAC = client MAC. Now we have everything to create the new ethernet header. Recall, though, that the received packet has destination IP of the client (not the destination MAC address) and thus we need to map this IP to the MAC address. Here comes the concept of the ARP table already residing in the router. Have a read in a above section before proceeding.
  4. Router the packet to the concerned client.

Let’s build it step by step. First let’s define normal stuff for the router, and let the clients come online.

import socket
import time
router = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
router.bind(("localhost", 8100))
router_send = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
router_send.bind(("localhost", 8200))
router_mac = "05:10:0A:CB:24:EF"server = ("localhost", 8000)
client1_ip = "92.10.10.15"
client1_mac = "32:04:0A:EF:19:CF"
client2_ip = "92.10.10.20"
client2_mac = "10:AF:CB:EF:19:CF"
client3_ip = "92.10.10.25"
client3_mac = "AF:04:67:EF:19:DA"
router_send.listen(4)client1 = None
client2 = None
client3 = None
while (client1 == None or client2 == None or client3 == None):
client, address = router_send.accept()

if(client1 == None):
client1 = client
print("Client 1 is online")

elif(client2 == None):
client2 = client
print("Client 2 is online")
else:
client3 = client
print("Client 3 is online")

You may have observed that I have two sockets, one on port 8100 and other on port 8200 inside a single application. This is completely normal; here it is because server → router → client 1/2/3. So one connection is to receive a packet from the server, and the other connection is to send another packet to a client. As you may observed in the client applications, we have the 8200 port defined inside clients, signifying that router_send is to connect to the clients and send some packet.

We also have the router_send to listen() for probable connections from clients. We also have a while loop that runs until all the clients are connected to port 8200. The socket objects are stored for further work: sending data received from the server.

It is also time to define the ARP table.

arp_table_socket = {client1_ip : client1, client2_ip : client2, client3_ip : client3}arp_table_mac = {client1_ip : client1_mac, client2_ip : client2_mac, client3_ip : client3_mac}

Two dictionaries: map destination IP to socket object to send data and map destination IP to destination MAC for ethernet header information.

Let’s perform the second step, establish a connection to the server and receive a very simple packet from it.

router.connect(server) while True:    received_message = router.recv(1024)    received_message =  received_message.decode("utf-8")

source_mac = received_message[0:17]
destination_mac = received_message[17:34] source_ip = received_message[34:45] destination_ip = received_message[45:56] message = received_message[56:]

print("The packed received:\n Source MAC address: {source_mac}, Destination MAC address: {destination_mac}".format(source_mac=source_mac, destination_mac=destination_mac))
print("\nSource IP address: {source_ip}, Destination IP address: {destination_ip}".format(source_ip=source_ip, destination_ip=destination_ip)) print("\nMessage: " + message)

Familiar sight this! We connect to the server, receive a packet, and based on the packet definition used dissect the packet.

Step 3 is the main router functionality. It involves seeing if the destination node is within the network spanned by this router, and then acting accordingly. If the node is not in the network, send the packet to another router. If the node is in the network, strip the ethernet header, and create a new one.

    ethernet_header = router_mac + arp_table_mac[destination_ip]    IP_header = source_ip + destination_ip    packet = ethernet_header + IP_header + message

destination_socket = arp_table_socket[destination_ip]

destination_socket.send(bytes(packet, "utf-8"))

Here, we create a new ethernet header, have the same IP header as before, get the socket object destination_socket for the client to send the packet to, and send it using the familiar utf-8 encoding.

Having the entire code as one:

import socket
import time

router = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
router.bind(("localhost", 8100))

router_send = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
router_send.bind(("localhost", 8200))

router_mac = "05:10:0A:CB:24:EF"

server = ("localhost", 8000)

client1_ip = "92.10.10.15"
client1_mac = "32:04:0A:EF:19:CF"
client2_ip = "92.10.10.20"
client2_mac = "10:AF:CB:EF:19:CF"
client3_ip = "92.10.10.25"
client3_mac = "AF:04:67:EF:19:DA"
router_send.listen(4)client1 = None
client2 = None
client3 = None
while (client1 == None or client2 == None or client3 == None):
client, address = router_send.accept()

if(client1 == None):
client1 = client
print("Client 1 is online")

elif(client2 == None):
client2 = client
print("Client 2 is online")
else:
client3 = client
print("Client 3 is online")
arp_table_socket = {client1_ip : client1, client2_ip : client2, client3_ip : client3}
arp_table_mac = {client1_ip : client1_mac, client2_ip : client2_mac, client3_ip : client3_mac}
router.connect(server) while True:received_message = router.recv(1024)
received_message = received_message.decode("utf-8")

source_mac = received_message[0:17]
destination_mac = received_message[17:34] source_ip = received_message[34:45] destination_ip = received_message[45:56] message = received_message[56:]

print("The packed received:\n Source MAC address: {source_mac}, Destination MAC address: {destination_mac}".format(source_mac=source_mac, destination_mac=destination_mac))
print("\nSource IP address: {source_ip}, Destination IP address: {destination_ip}".format(source_ip=source_ip, destination_ip=destination_ip)) print("\nMessage: " + message)


ethernet_header = router_mac + arp_table_mac[destination_ip]
IP_header = source_ip + destination_ip packet = ethernet_header + IP_header + message

destination_socket = arp_table_socket[destination_ip]

destination_socket.send(bytes(packet, "utf-8"))
time.sleep(2)

Testing

Since the router requests connection to the server, and the clients request connection to the router, the following order of invoking Python scripts must be followed:

  1. Launch the server script
Nothing happens! The server is right now in the state waiting for the router to connect to it.

2. Launch the router script

Nothing happens! The router is waiting for the clients to connect to it.

3. Launch the client script

Client 1 comes online. Nothing happens! The client is now waiting for the router to send something so it may display it.
Router detects client 1 is online.

4. Launch other clients

When client 2 comes online, router detects the same.
When client 3 comes online, router detects the same.
As soon as all the clients are online, router sends a connection request to the server and the server (already waiting for the same) accepts it (the top socket object printed is the router socket connected). The server prompts the user for some message to send over the network.

5. Send some messages through the server

Some messages and destination IP addresses defined in the server.

Now see how the other applications have updated information:

Note the router receives both the messages
Here is client 2 receiving the message intended for it
Here is client 3 receiving the message intended for it
Client 1 obviously gets nothing

Conclusion

The above application may be improved in a lot of ways:

  1. Introduce complexity in the headers, trying to build the standard header formats.
  2. This was a simple application simulating communication within a single network. Introduce communication between two/three different networks. A good exercise is to have three networks (A, B, C) and two routers (X and Y) such that A — X — B and B — Y — C. Now try sending a packet from A to C.
  3. Usually, ARP tables are complex and not manually written down. In the code above, when the router has received a packet and dissected it, it sees the destination IP address. Now, if the destination IP is not in the ARP table, the router will issue a FF:FF:FF:FF:FF:FF broadcast message that goes to all clients in the network. The actual client node recognises it’s IP and sends back the MAC address, and the router stores this new IP-MAC mapping in its ARP table. Also, ARP tables are frequently updated to account for changes in the network (new nodes joining the network, and older ones leaving them).

I hope you enjoyed this one. I’ll happy to hear from you for further suggestions.

Have a good day!

--

--