Step-by-Step Guide to Creating a VPN Tunnel Using Python

Learn to Set Up a Safe VPN Tunnel with Python, Linux, and Docker Containers

Step-by-Step Guide to Creating a VPN Tunnel Using Python

Introduction

Virtual Private Networks (VPNs) are super important for keeping your communication safe over public networks. In this guide, we'll explore the details of VPN tunneling using Python and Linux's TUN/TAP interfaces. This hands-on lab activity shows you how to set up a VPN tunnel that securely routes packets between machines.

This blog is based on the SEED Labs activity VPN Tunneling, where I used their provided lab setup to understand and solve the lab's challenges. In this blog, we'll cover:

  • Setting up the network.

  • Creating and configuring the TUN interface.

  • Forwarding IP packets through the tunnel.

  • Establishing the VPN server.

  • Managing bidirectional traffic.

  • Creating and configuring the TAP interface.


  1. Network Setup

We'll set up a VPN tunnel between a computer (client) and a gateway, so the computer can safely connect to a private network through the gateway. For this setup, we'll need at least three machines: a VPN client (which will also be Host U), a VPN server (acting as the router/gateway), and a host within the private network (Host V).

IP Address Configuration:

MachineNAT Network IPInternal Network IPTUN interface
Host U (VPN Client)10.9.0.5-192.168.53.99
Gateway (VPN Server/ Router)10.9.0.11192.168.60.11192.168.53.98
Host V (Private)-192.168.60.5

Now, let's use docker-compose.yml to set up the containers.

We'll test the network setup by using ping to see if the machines can successfully receive ICMP packets from one another.
Host U should be able to communicate with the VPN server.

VPN Server should be able to communicate with Host V.

Host U should not be able to communicate with Host V.


  1. Create and Configure TUN Interface

2a. Name of the Interface

Python Script: tun2a.py

#!/usr/bin/env python3

import fcntl
import struct
import os
import time
from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

while True:
    time.sleep(10)

After saving the file, make the Python script executable by using chmod +x tun2a.py. If necessary, run it with root privileges.

Next, open a new terminal on Host U and run the command ip address to check if the tun interface has been named successfully.

2b. Set up the TUN Interface

Before we can start using the interface, we have two simple tasks to do. First, we need to give it an IP address. Then, we need to turn it on, since it's currently turned off.

Python Script: tun2b.py

#!/usr/bin/env python3

import fcntl
import struct
import os
import time
from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

os.system("ip addr add 192.168.53.99/24 dev {}".format(ifname))
os.system("ip link set dev {} up".format(ifname))

while True:
    time.sleep(10)

Once you've run the code above, go ahead and execute the ip address command again.

In the first line, you'll see UP, LOWER_UP, which means the tun0 tunnel interface is up and connected to the network. The last line confirms that the IP address subnet 192.168.53.99/24 has been assigned to the tun interface.

2c. Read from the TUN Interface

In this task, we're going to read data from the TUN interface. Everything coming from the TUN interface is an IP packet. We can turn the data we get from the interface into a Scapy IP object, which lets us print out each part of the IP packet.

Python Script: tun2c.py

#!/usr/bin/env python3

import fcntl
import struct
import os
import time
from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

os.system("ip addr add 192.168.53.99/24 dev {}".format(ifname))
os.system("ip link set dev {} up".format(ifname))

while True:
    time.sleep(10)

Go ahead and run the tun2c.py program on Host U, set up the TUN interface as needed, and then try out the following experiments.

  • On Host U, try pinging a host in the 192.168.53.0/24 network. What does the tun2c.py program show? What's going on here, and why?
    You'll see ICMP packets being sent, as shown below.

    At the same time, tun2c.py displays IP / ICMP 192.168.53.99 > 192.168.53.20 echo-request 0 / Raw, just like you see below.

    This means the tun interface (192.168.53.99) is sending ICMP packets to the host you're pinging (192.168.53.20). This happens because when you ping the host 192.168.53.20, the ICMP packet travels through the tun interface since they're on the same subnet.

  • On Host U, try pinging a host in the internal network 192.168.60.0/24. Does tun2c.py show anything? Why?

    Even though this host doesn't actually exist, you'll notice that the ICMP packets are still sent out like this:

    However, tun2c.py doesn't show anything. This is because the host is within the internal network, so the packets aren't sent through the tun interface. That's why tun2c.py only prints when it receives packets.

2d. Write to the TUN Interface

Once you receive a packet from the TUN interface, if it's an ICMP echo request, go ahead and create an echo reply packet and send it back through the TUN interface. For a fun twist, try writing some random data to the interface instead of an IP packet, and see what happens!

Python Script: tun2d.py

#!/usr/bin/env python3

import fcntl
import struct
import os
import time
from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

os.system("ip addr add 192.168.53.99/24 dev {}".format(ifname))
os.system("ip link set dev {} up".format(ifname))

while True:
# Get a packet from the tun interface
    packet = os.read(tun, 2048)
    if True:
        ip = IP(packet)
        print(ip.summary())
        #Send out a spoof packet using the tun interface
        if(ip.haslayer(ICMP)):
            print("this is ICMP packet")
            icmppayload = ip.getlayer(ICMP)
            print(icmppayload.display())
            if(icmppayload.type == 8):
                print("this is ICMP request, send icmp reply")
                newip = IP(src=ip.dst, dst=ip.src)
                newpayload = icmppayload
                #change the icmp type to 0 (imcp reply) and update the checksum
                newpayload.type = 0
                newpayload.chksum -= 8
                print(newpayload.display())
                newpkt = newip/newpayload/icmppayload.load
                os.write(tun, bytes(newpkt))

                print("Hello World")
                os.write(tun, bytes("Hello World", encoding="ascii"))
                print("Arbitrary data written to the interface")

From the above, you can see that since we wrote arbitrary data instead of IP packets to the interface, the bytes sent through the tunnel weren't valid, and that's why the request pings didn't get any responses.


  1. Send the IP Packet to VPN Server Through a Tunnel

In this task, we'll take the IP packet we get from the TUN interface and place it into the UDP payload field of a new IP packet to send it to another computer. Basically, we're putting the original packet inside a new one, a process known as IP tunneling. Setting up the tunnel is just like regular client/server programming and can be done using either TCP or UDP. For this task, we'll be using UDP, where we'll nest an IP packet inside the payload field of a UDP packet.

Python Script: tun_server.py

#!/usr/bin/python3

from scapy.all import *

#Create UDP socket for receive
IP_A = "0.0.0.0"
PORT = 9090

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((IP_A, PORT))

while True:
    data, (ip, port) = sock.recvfrom(2048)
    print("{}:{} --> {}:{}".format(ip, port, IP_A, PORT))
    pkt = IP(data)
    print("From sock Inside: {} --> {}".format(pkt.src, pkt.dst))

We'll run the tun_server.py program on the VPN Server. This program is a basic UDP server. It listens on port 9090 and prints out whatever it receives. The program expects the data in the UDP payload to be an IP packet, so it converts the payload into a Scapy IP object and prints out the source and destination IP addresses of the IP packet inside.

Python Script: tun_client.py

#!/usr/bin/env python3

import fcntl
import struct
import os
import time
from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

os.system("ip addr add 192.168.53.99/24 dev {}".format(ifname))
os.system("ip link set dev {} up".format(ifname))

os.system("ip route add 192.168.60.0/24 dev {}".format(ifname))

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
# Get a packet from the tun interface
    packet = os.read(tun, 2048)
    if True:
        sock.sendto(packet, ("10.9.0.11", 9090))

Run the tun_server.py program on the VPN Server, and then start tun_client.py on Host U. To check if the tunnel is working, try pinging any IP address in the 192.168.53.0/24 network.

As you can see, the packets are being sent, but there aren't any ICMP echo reply packets. Meanwhile, on the VPN Server, you'll notice messages like: Inside: 192.168.53.99 --> 192.168.53.20 being printed, as shown below.

These printed statements show that the VPN Server is getting the ICMP packets from the tun interface (192.168.53.99), which are meant for the host in the 192.168.53.0/24 network (192.168.53.20). Our main goal is to reach the hosts in the private network 192.168.60.0/24 using the tunnel. Let's try pinging Host V to see if the ICMP packet goes to the VPN Server through the tunnel.
On Host U, we'll ping a host in the 192.168.60.0/24 network, specifically 192.168.60.20, as shown below. Make sure tun_client.py is still running in a separate terminal.

However, the VPN Server isn't getting the packets, so the tun_server.py script doesn't show any output. Don't worry, this is normal because we haven't set up the routing for the 192.168.60.0/24 subnet through the tunnel yet. Once we sort out the routing on Host U, we'll try pinging 192.168.60.20 again, as shown below.

On the VPN Server below, we can now happily see that the ICMP packets are being received.

  1. Set Up the VPN Server

After tun_server.py receives a packet from the tunnel, it needs to pass the packet to the kernel. This way, the kernel can send the packet to where it needs to go. We’ll do this using a TUN interface, just like in Task 2. Please update tun_server.py to do the following:

  • Create and set up a TUN interface.

  • Receive data from the socket interface and handle it as an IP packet.

  • Write the packet to the TUN interface.

Python Script: tun_server4.py

#!/usr/bin/env python3

import fcntl
import struct
import os

from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000
TUN_IP = "192.168.53.98"

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

# Set up the tun interface
os.system("ip addr add {}/24 dev {}".format(TUN_IP, ifname))
os.system("ip link set dev {} up".format(ifname))

IP_A = "10.9.0.11"
PORT = 9090

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((IP_A, PORT))

while True:
    data, (ip, port) = sock.recvfrom(2048)
    print("{}:{} --> {}:{}".format(ip, port, IP_A, PORT))
    pkt = IP(data)
    os.write(tun, bytes(pkt))

Before you run the updated tun_server4.py, make sure to turn on IP forwarding. By default, a computer acts as a host, not a gateway. Since the VPN Server needs to forward packets between the private network and the tunnel, it has to work like a gateway. So, we need to enable IP forwarding for it to act like one. IP forwarding is already turned on in the router container. You can check the docker-compose.yml file, where you'll find this entry:

sysctls:- net.ipv4.ip_forward=1

On the VPN Server, run the tun_server4.py code. Then, start tcpdump to track packets. On the VPN Client, execute the tun_client.py script you created in task 3. In another terminal on the VPN Client, run ip route add 192.168.60.0/24 dev lin0 via 192.168.53.99 and then ping Host V with ping 192.168.60.5.

Here's what the packet trace on Host V looks like:

As you can see, Host V is getting the echo request packets from Host U / VPN Client, which means everything worked perfectly!


  1. Handling Traffic in Both Directions

Once you've set up the tunnel in one direction, you can send packets from Host U to Host V. But, you'll notice that the response packets from Host V get lost because the tunnel only works one way. To fix this, we need to handle traffic in both directions.

Our TUN client and server programs need to manage data from both the TUN and socket interfaces, which are represented by file descriptors. Instead of constantly checking for data, which isn't efficient, we can use blocking reads. This method pauses the process when there's no data and resumes only when data is available, saving CPU time.

The read-based blocking method works great for one interface, but for multiple interfaces, we need to block on all of them. Linux offers the select() system call, which lets a program keep an eye on multiple file descriptors at once. We gather the file descriptors into a set and pass it to select(), which pauses the process until data is ready on one. This way, we can see which file descriptor has data. In the Python code below, select() keeps an eye on both a TUN and a socket file descriptor.

Python Script: tun_server5.py

#!/usr/bin/env python3

import fcntl
import struct
import os

from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000
TUN_IP = "192.168.53.98"

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

# Set up the tun interface
os.system("ip addr add {}/24 dev {}".format(TUN_IP, ifname))
os.system("ip link set dev {} up".format(ifname))

IP_A = "10.9.0.11"
PORT = 9090

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((IP_A, PORT))

while True:
    # this will block until at least one interface is ready
    ready, _, _ = select.select([sock, tun], [], [])
    for fd in ready:
        if fd is sock:
            data, (ip, port) = sock.recvfrom(2048)
            pkt = IP(data)
            print("From socket <==: {} --> {}".format(pkt.src, pkt.dst))
            os.write(tun, data)

        if fd is tun:
            packet = os.read(tun, 2048)
            pkt = IP(packet)
            print("From tun ==>: {} --> {}".format(pkt.src, pkt.dst))
            sock.sendto(packet, (ip, port))

Python Script: tun_client5.py

#!/usr/bin/env python3

import fcntl
import struct
import os
import time
from scapy.all import *

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

# Create the tun interface
tun = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack('16sH', b'tun%d', IFF_TUN | IFF_NO_PI)
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr)

# Get the interface name
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00")
print("Interface Name: {}".format(ifname))

os.system("ip addr add 192.168.53.99/24 dev {}".format(ifname))
os.system("ip link set dev {} up".format(ifname))

os.system("ip route add 192.168.60.0/24 dev {}".format(ifname))

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

while True:
    # this will block until at least one interface is ready
    ready, _, _ = select.select([sock, tun], [], [])
    for fd in ready:
        if fd is sock:
            data, (ip, port) = sock.recvfrom(2048)
            pkt = IP(data)
            print("From socket <==: {} --> {}".format(pkt.src, pkt.dst))
            os.write(tun, bytes(pkt))

        if fd is tun:
            packet = os.read(tun, 2048)
            pkt = IP(packet)
            print("From tun ==>: {} --> {}".format(pkt.src, pkt.dst))
            sock.sendto(packet, ("10.9.0.11", 9090))

On Host V, you'll need to set the default gateway by using the following command:

route add default gw 192.168.60.11

To see the VPN tunnel in action and follow the sequence of events, you can easily ping from Host U to Host V using ping 192.168.60.5, just like this:

On Host U, tcpdump captured this trace:

On the Gateway, tcpdump captured this trace:

On Host V, tcpdump captured this trace:

The echo request packets start their journey from Host U's TUN interface (192.168.53.99) and head over to the Gateway's TUN interface (192.168.53.98). From there, they are sent to the Gateway's socket at 10.9.0.11 and then forwarded by the Gateway (192.168.60.11) to Host V (192.168.60.5) through the Internal Network.

Once Host V (192.168.60.5) receives the request, it sends back an echo reply packet. This reply retraces the path of the request: it travels from Host V (192.168.60.5) to the Gateway (192.168.60.11) through the Internal Network, then to the Gateway's socket, which sends it to the Gateway's TUN interface (192.168.53.98), and finally back to Host U's TUN interface (192.168.53.99) and Host U's socket at 10.9.0.5.


  1. Experiment with the TAP Interface

The TAP interface is quite similar to the TUN interface, but there's a key difference: TAP connects to the MAC layer, while TUN connects to the IP layer. This means TAP packets have a MAC header, whereas TUN packets only have an IP header. TAP can manage different types of frames, like ARP frames, not just IP packets.

For our little experiment, we'll use a program with just the VPN client container. The code for creating TUN and TAP interfaces is pretty much the same, with the main difference being the interface type: IFF_TAP for TAP and IFF_TUN for TUN. The setup for both interfaces is identical.

Python Script: tap.py

#!/usr/bin/env python3 

import fcntl 
import struct 
import os 
from scapy.all import * 

TUNSETIFF = 0x400454ca 
IFF_TUN   = 0x0001 
IFF_TAP   = 0x0002 
IFF_NO_PI = 0x1000 

tun = os.open("/dev/net/tun", os.O_RDWR) 
ifr = struct.pack('16sH', b'tap%d', IFF_TAP | IFF_NO_PI) 
ifname_bytes  = fcntl.ioctl(tun, TUNSETIFF, ifr) 
ifname = ifname_bytes.decode('UTF-8')[:16].strip("\x00") 
print("Interface Name: {}".format(ifname)) 

# Set up the tun interface 
os.system("ip addr add 192.168.53.99/24 dev {}".format(ifname)) 
os.system("ip link set dev {} up".format(ifname)) 

while True: 
   packet = os.read(tun, 2048) 
   if True: 
      print("--------------------------------") 
      ether = Ether(packet) 
      print(ether.summary()) 

      # Send a spoofed ARP response 
      FAKE_MAC   = "aa:bb:cc:dd:ee:ff" 
      if ARP in ether and ether[ARP].op == 1 : 
         arp       = ether[ARP] 
         newether  = Ether(dst=ether.src, src=FAKE_MAC) 
         newarp    = ARP(psrc=arp.pdst, hwsrc=FAKE_MAC, 
                         pdst=arp.psrc, hwdst=ether.src, op=2) 
         newpkt     = newether/newarp 

         print("***** Sending Fake response: {}".format(newpkt.summary())) 
         os.write(tun, bytes(newpkt))

To give your TAP program a try, you can use the arping command on any IP address. This command sends an ARP request for the chosen IP address through the specified interface. If your spoof-arp-reply TAP program is working, you should see a response.

arping-I tap0 192.168.53.33

arping-I tap0 1.2.3.4


Conclusion

This project provides a foundational understanding of VPN tunneling and packet routing. By combining Python and Linux's networking features, we successfully implemented a custom VPN solution. Future work could include adding encryption for a more secure tunnel.


Code Repository

Find the complete code and setup instructions on my GitHub.


Reference

SEED Labs: VPN Tunneling Lab Documentation by Wenliang Du