The worst VPN

Saturday, 4 March 2023








linux, internet


What is the worst way to make a VPN?

VPNs are somewhat popular these days with seemingly every podcast, steamer and YouTube creator being sponsored by one of them. Many of them claim to not log what you do so I thought I would try to make a VPN that required logging everything you did.

What is a VPN?

A Virtual Private Network has some uses to gain secure access to remote services, or the more popular consumer approach to mask your network activity from your ISP. I don’t really care about any of these use cases for my VPN, because its so bad it shouldn’t have any users.

Why do I care?

Previously, I worked for a company that made a VPN for almost a decade so I’m fairly familiar with how they work. I always thought it would be funny to use a different technology to transport the packets from one peer to another and for some reason finally decided to give it a shot.

The forced-logging VPN

This VPN, pgVPN, uses a Postgres database as the network transport layer. It creates a tun device and whenever a packet is read on it, parses it and executes an INSERT statement with the destructured IP details. A trigger in the Postgres database then executes a procedure to send a pg_notify that a new packet is available. The peer of the tunnel then can act on this notification and SELECT any unreceived packets from the database and write them out of it’s tun device, completing the connection.

How well does it work

The best thing I can say about it is that it technically works. Running both peers and the Postgres database on some containers on my host, I managed to benchmark a TCP connection at 3.93 Mbits/sec according to iperf3.

    Accepted connection from, port 56296
[  5] local port 5201 connected to port 56310
[ ID] Interval           Transfer     Bitrate
[  5]   0.00-1.00   sec   491 KBytes  4.02 Mbits/sec
[  5]   1.00-2.00   sec   546 KBytes  4.47 Mbits/sec
[  5]   2.00-3.00   sec   568 KBytes  4.66 Mbits/sec
[  5]   3.00-4.00   sec   578 KBytes  4.74 Mbits/sec
[  5]   4.00-5.00   sec   452 KBytes  3.71 Mbits/sec
[  5]   5.00-6.00   sec   280 KBytes  2.29 Mbits/sec
[  5]   6.00-7.00   sec   433 KBytes  3.54 Mbits/sec
[  5]   7.00-8.00   sec   509 KBytes  4.17 Mbits/sec
[  5]   8.00-9.00   sec   484 KBytes  3.96 Mbits/sec
[  5]   9.00-10.00  sec   494 KBytes  4.04 Mbits/sec
[  5]  10.00-11.00  sec   436 KBytes  3.57 Mbits/sec
[  5]  11.00-11.13  sec  65.0 KBytes  3.95 Mbits/sec
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate
[  5]   0.00-11.13  sec  5.21 MBytes  3.93 Mbits/sec

I had no security configured with my connection to Postgres and no encryption of the data so this is something of a best case scenario. Running TCP over TCP is generally considered a bad idea, which is what this ends up being.

The benefit to this method is a full and complete log of all traffic managed by the VPN. Also, both peers of the VPN should work even behind strict NAT since they aren’t connecting to each other directly. You could easily replay any packets that were handled by this tunnel and/or sell the historical metrics to interested third parties. The downsides include the incredibly slow speeds, and the high storage requirements unless you clear out old packets from the database.

Implementation details

At first I made a simple table, with a trigger and function to send a pg_notify whenever a new packet has been inserted.

CREATE TABLE ipv4_packet (
    id serial PRIMARY KEY,
    ihl smallint,
    tos smallint,
    total_length smallint,
    identification int,
    flags smallint,
    fragment_offset smallint,
    ttl smallint,
    protocol smallint,
    header_checksum int,
    source_address inet,
    dest_address inet,
    options bytea,
    payload bytea,
    sender inet,
    received int DEFAULT 0

CREATE OR REPLACE FUNCTION notify_packet_ready()
    row RECORD;
row = NEW;
PERFORM pg_notify('packet_ready', '' ||;
$$ LANGUAGE plpgsql;

AFTER INSERT ON ipv4_packet
FOR EACH ROW EXECUTE PROCEDURE notify_packet_ready();

The tunnel is a simple Go application that reads from a tun device, parses the data into the header fields using ipv4.ParseHeader and then performs an INSERT into the ipv4_packet table with the details. Another instance of the tunnel program running elsewhere would be doing a listen packet_ready and then query all non-received rows from the sender, parsing them back into bytes and writing out the tun device.

After benchmarking this and seeing the poor performance I thought about simplifying the schema and packet processing by changing the schema to just

CREATE TABLE raw_packet (
payload BYTEA,
sender INET,
received INT DEFAULT 0

However, this made no noticeable difference. I think the time to commit each row in postgres is the limiting factor here, I can never observe more than a single packet being available unreceived at a time.

A next step to improve performance would probably be to try and batch reads from the tun device into a single update, but this would be at the cost of some slight increase in latency but should increase performance a bit.

You can look at the code on my GitHub, but I wrote it in Go and I don’t know Go very well so I might not recommend that.


I didn’t expect good performance with this, and what I got was not unexpected. I didn’t really learn much other than a tiny bit about some Go. I probably should have written it in Python since it would have taken a day less probably.

Some additional findings can be read about in part 2 .

Written Saturday, 4 March 2023

Modified Friday, 20 March 2020.

Tagged with linux and internet.

Categorized as “

What do you think of this post?