はじめに

VPNはVirtual Private Networkのことで、別のネットワークにいる端末があたかも同じネットワークにいるかのように振る舞うようにする技術です。

VPNはクライアント・サーバ間で本当に送信したいIPパケットを暗号化し、それをデータとしてTCPやUDPで送信します。もちろんこのTCPやUDPのデータを送信するのはIPパケットです*1。余分なIPヘッダとTCPヘッダが無駄ですがこれは致し方ない犠牲です。そして、TCPやUDP経由でVPNサーバに到着したIPパケットは、そこから通常のIPパケットとして宛先に送信されます。

どこかのサイトにアクセスするとき、宛先がわからなければどうしようもないのでIPパケットには必ず宛先アドレスが書かれていますが、ここは暗号化できないので、たとえHTTPS通信であっても盗聴者やISP(インターネットサービスプロバイダ)にはどこにアクセスしているかわかってしまいます。VPNであれば本当に送信したいIPパケットを暗号化しているのでISPから見たらVPNサーバと何やら通信しているようにしか見えないのです。こうすることでISPによるトラッキングからも逃れることができます。ただ、VPNサーバにはどこに接続しようとしているかわかるので、VPNプロバイダを利用してしまうと、信用する対象がISPからVPNプロバイダに移るだけになってしまいます。そこでVPNサーバも自前で用意すればアクセスログも自分しか見れないので比較的プライバシーが確保されます。

この記事ではネットワークの学習のためのVPNを作成しますので認証や暗号化は省きます(というのは建前で実装するのが面倒だっただけということは忘れてください)。実用したい人はOpenVPN*2を利用するのがいいと思います。この記事のVPNもOpenVPNを参考にしているのですんなり導入できるようになるはずです。なお、この記事で作るプログラムのソースコードはGitHub*3に置いてあります。

環境

サーバ側

  • Arch Linux (5.8.14-arch1-1)

  • ConohaのVPS*4でグローバルIPv4アドレスあり

  • ネットワークインターフェースはeth0

  • VPNのプライベートIPアドレスは10.0.0.1に設定

  • TUNデバイスの名前はtunserverとします

クライアント側

  • Ubuntu 20.04 LTS (5.4.0-51-generic)

  • 一般家庭のLAN (192.168.11.0/24)

  • ネットワークインターフェースはeno1

  • VPNのプライベートIPアドレスは10.0.0.2に設定

  • TUNデバイスの名前はtunclientとします

サーバ・クライアント間はUDPでポートは1195とします。次の図が今回作るVPNの全体像です。次節以降適宜参照してください。

アプリケーションからのパケットを受け取る

まっ先に問題となるのが通常はインターネットに流れていくアプリケーションからのパケットをどうやって受け取るのかということです。LinuxではアプリケーションがTCP/UDP通信した場合、カーネルによってIPパケットが作られます。すべてのパケットはカーネルが保持しているルーティングテーブルを参照して宛先が決められます。次のリストはデフォルトの状態のルーティングテーブルです。192.168.11.0/24は私のLANで、eno1はPCの有線LANです(eno1のようなネットワークインターフェイスの名前はip addrコマンドで調べることができます)。

1
2
3
4
5
6
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 192.168.11.1 0.0.0.0 UG 100 0 0 eno1
192.168.11.0 0.0.0.0 255.255.255.0 U 100 0 0 eno1
192.168.11.1 0.0.0.0 255.255.255.255 UH 100 0 0 eno1

基本的にこの中から宛先アドレスが最も一致し、Metricが小さいものが選択されます。0.0.0.0はネットマスクが0.0.0.0ですので0.0.0.0/0ということですべてのIPアドレスを示します。つまりすべての通信はeno1インターフェースから出ていくことになります(1行目)。VPNクライアントではパケットを横取りしてUDP経由で送りたいのでeno1から出ていく代わりにプログラムに渡すことができれば良さそうです。

Linuxにはそれを実現できるTUNという仮想ネットワークデバイスがあり、eno1の代わりに使うことができます。つまり、アプリケーションはTUNへパケットを送信し、帰ってきたパケットをTUNから受け取ることができるということです。これを利用することで各種アプリケーションからのIPパケットをVPNクライアントが受け取ることができるようになります。このデバイスは通常のファイルと同じように扱うことができ、ファイルをread()write()で読み書きしたように、TUNをread()するとTUNに入ったパケットを読み取り、write()するとインターネットからパケットが送り返されたようになります。

TUNデバイスは次のようにして作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 希望する仮想デバイス名を入れる
int tun_alloc(char *dev) {
struct ifreq ifr = {};
int fd, err;

if ((fd = open("/dev/net/tun", O_RDWR)) < 0) {
printf("open()");
return fd;
}

// IFF_TUN : TUNを使う
// IFF_NO_PI: 付加情報をつけないで生のパケットを読み書きする
ifr.ifr_flags = IFF_TUN | IFF_NO_PI;
if (*dev) {
strncpy(ifr.ifr_ifrn.ifrn_name, dev, IFNAMSIZ);
}

if ((err = ioctl(fd, TUNSETIFF, (void *)&ifr)) < 0) {
close(fd);
perror("ioctl()");
return err;
}
strncpy(dev, ifr.ifr_ifrn.ifrn_name, IFNAMSIZ);
return fd;
}

デバイスを作成したらリンクアップします。Cのsystem関数で外部コマンドとしてipコマンドを呼び出しています。こうしているのは起動時に毎回このコマンドを打つのが面倒だからというだけです。

1
system("ip link set tunclient up");

次にTUNデバイスにIPアドレスを割り当てます。これは何でもいいですが、IPv4のプライベート用IPアドレス空間を使うのがいいでしょう。もし存在するIPアドレスに割り当ててしまうとそのサイトにアクセスできなくなります。ここではサーバに10.0.0.1を、クライアントに10.0.0.2を割り当てます(サブネットマスクは/24としておきます)。

1
system("ip addr add 10.0.0.2 dev tunclient");

VPNクライアントプログラムがアプリケーションからのパケットを受け取るために、すべての通信がeno1ではなくTUNに行くようにルーティングを行います。eno1に行くエントリよりもMetricが小さくなるようにTUNデバイスへの経路を追加します(クライアントプログラムでTUNデバイスを作成した後でないと設定できません)。ここではクライント側のTUNデバイスということで、インターフェース名はtunclientにします。

1
2
$ sudo ip route add 0.0.0.0/0 dev tunclient
# ip route で追加されたことを確認します。

これによりルーティングテーブルに次のエントリが追加されます。

1
2
Destination Gateway  Genmask   Flags Metric Ref Use Iface
0.0.0.0 0.0.0.0 0.0.0.0 U 0 0 0 tunclient

ただ、ひとつだけ問題があります。この設定ではVPNサーバ宛の通信までTUNに入ってしまいますから、VPNクライアントからVPNサーバに送ったパケットが無限ループしてしまいます。そこでVPNサーバ宛の通信は直接eno1から出るようにします。182.58.392.21がサーバアドレスで、192.168.11.1がLANのゲートウェイ(ルータ)です。

1
$ sudo ip route add 182.58.392.21 via 192.168.11.1 dev eno1
1
2
3
4
5
6
7
8
9
10
11
// TUNでアプリケーションから受信
int datalen;
if ((datalen = read(tunfd, data, sizeof(data))) <= 0) {
perror("read tunfd");
} else {
// UDPでサーバに送信
if (sendto(sockfd, data, datalen, 0,
(struct sockaddr *)&server_addr,
sizeof(server_addr)) < 0)
perror("write sockfd");
}

LAN環境下での双方向通信の仕組み

通信の両端がグローバルIPアドレスであれば特に何もしなくても通信できますが、少なくとも片方がルータ内にいて、プライベートIPアドレスしか持っていない場合にはひと捻り必要です(プログラムやコマンドでどうこうするわけではないです)。

サーバにはグローバルIPアドレスが割り当てられていて、クライアントは家庭内LANにいる環境を想定します。サーバのグローバルIPアドレスがわかるのでLAN内の端末から出たパケットはサーバに到達することができます。しかし、その逆のサーバからクライアントの場合、通常はひとつの家庭にひとつのグローバルIPアドレスが割り当てられているので、家庭内LANを外から見るとLAN内のどの端末も同じグローバルIPアドレスを持つように見えます。つまり、サーバから出たパケットはLANを目指しますが、家庭内LANの入り口までしか行けず、その先どの端末に行けばいいのかわからなくなってしまいます。これではもちろん双方向に通信することができないので、IPマスカレードと呼ばれる仕組みが用いられます。これはまず間違いなくどの家庭用ルータにも実装されていて、LAN内の送信元のプライベートIPアドレスとポート番号と、外から見たグローバルIPアドレスと任意のポート番号を変換するようにできています。ここでパケットそのものを書き換え、その対応を覚えておくことで、一定のポート番号を使っている限りサーバからの通信をLAN内の各端末に振り分けられるようになります。

しかしこの場合、サーバからクライアントへ通信を開始することはできません。まだクライント側のルータがポートの対応を知らないからです。ただ、クライアントが先にサーバへの通信を開始すれば、ルータがプライベートIPアドレスとポート番号の対応を覚えているので、サーバからクライアントへの通信も通ります。この覚えておく時間はルータによって違うと思いますが、Linuxのカーネルパラメータのデフォルト値を見たところUDPの場合30秒となっていました。おそらくルータのOSにもLinuxに似たようなものが使われているはずですのでだいたいこのくらいでしょう。つまり、30秒間通信が途切れるとルータは対応付けを忘れてしまうのでサーバからのパケットを受け取れなくなってしまうということになります。今回の場合はPCで発生するほぼ全てのIPパケットをやり取りしているので30秒間全く通信がないということはまずないでしょう。

1
2
$ cat /proc/sys/net/netfilter/nf_conntrack_udp_timeout
30

サーバとクライアントのどちらもグローバルIPアドレスを持っていない場合、この手法を応用したUDPホールパンチングというものを使いますが、VPNサーバはまずグローバルIPアドレスを持っていると思うので飛ばします。

プログラムは次のようになります。今回はクライアントから通信を開始するので問題ありません。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// クライアントから受信
int datalen;
if ((datalen = read(sockfd, data, sizeof(data))) <= 0) {
perror("recv from client");
} else {
// インターネットに送信
struct sockaddr_in to = {
.sin_family = AF_INET,
.sin_addr.s_addr = ((struct iphdr *)data)->daddr,
};
if (sendto(eth, data, datalen, 0,
(struct sockaddr *)&to, sizeof(to)) < 0) {
perror("sendto internet");
}
}

ただし、サーバやクライアントでファイアウォールが動いている場合には受信できないので、ポートを開けておきます。

1
2
$ sudo iptables -A INPUT -p udp --dport 1195 -j ACCEPT
# sudo systemctl reload iptables.service

VPNからインターネットへ

このIPマスカレードの仕組みをVPNサーバからインターネットにアクセスする場合にも使います。通常VPNは複数のクライアントがいるので、さきほどの家庭内LANの各端末をVPNの各クライアントに、LANをVPNに置き換えればIPマスカレードの必要性はわかります。つまり、VPNのプライベートIPアドレスとポート番号と、サーバのグローバルIPアドレスと任意のポートを変換するということです。これはサーバ側でNAT(Network
Address
Translation)テーブルに手を加えればいいので、iptables*5を利用して次のようにします。10.0.0.0/24はVPNの各クライアントのプライベートIPアドレスで、eth0はインターネットに接続されているネットワークデバイスです。

1
$ sudo iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE

IPマスカレードを設定して、次のプログラムでIPパケットをインターネットに送信します。このとき送信元アドレスはクライアントの10.0.0.2のままです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// クライアントから受信
int datalen;
if ((datalen = read(sockfd, data, sizeof(data))) <= 0) {
perror("recv from client");
} else {
// インターネットに送信
struct sockaddr_in to = {
.sin_family = AF_INET,
.sin_addr.s_addr = ((struct iphdr *)data)->daddr,
};
if (sendto(eth, data, datalen, 0,
(struct sockaddr *)&to, sizeof(to)) < 0) {
perror("sendto internet");
}
}

上の例でパケットには正しい宛先アドレスが書かれているのにもかかわらず、struct sockaddr_in toへコピーしています。無駄に見えますが仕様のようで、sendto()で宛先を指定しなければいけません。

インターネットからVPNへ

サーバ側のIPマスカレードによりVPNの各クライアントはインターネットへパケットを送信することができるようになったわけですが、インターネットから帰ってきたパケットは勝手にはVPN内に入ってくれません。ルーティングしてVPN内に入れる必要があります。IPマスカレードによって10.0.0.0/24のプライベートアドレスに戻されたパケットがVPNサーバプログラムで操作できればいいわけですから、クライアントの時と同じようにTUNデバイスを使います。つまり、eth0から入ってきた10.0.0.0/24宛のパケットをTUNに向けるということです。これをIPフォワーディングといいます。あとはクライアントのときと同様にIPアドレスの設定、リンクアップ、ルーティングを行います。

1
2
3
system("ip addr add 10.0.0.1 dev tunserver");
system("ip link set tunserver up");
system("ip route add 10.0.0.0/24 dev tunserver");

ただし、Linuxでこのようなことをするときはルーティングだけでなく、明示的にIPフォワーディングを許可する必要があります。

1
2
3
# /etc/sysctl.conf
net.ipv4.ip_forward=1
# sudo sysctl -p で反映させます。

ここでもファイアウォールが効いているので、eth0に入ってきたパケットが、tunserverに行くのを許可しておきましょう。

1
$ sudo iptables -A FORWARD -i eth0 -o tunserver -d 10.0.0.0/24 -j ACCEPT

VPNサーバからVPNクライアントへ

サーバ側でTUNデバイスに入ったパケットはプログラムで読み取れるようになりますから、これをクライアントへ転送すればいいのです。

1
2
3
4
5
6
7
8
9
10
11
12
// インターネットからパケット受信
int datalen;
if ((datalen = read(tunfd, data, sizeof(data))) <= 0) {
perror("read tunfd");
} else {
// クライアントに送信
if (sendto(sockfd, data, datalen, 0,
(struct sockaddr *)&client_addr,
sizeof(client_addr)) < 0) {
perror("sendto client");
}
}

VPNクライアントからアプリケーションへ

やっとのことでクライアントに到着したパケットは前述の通りTUNデバイスにwrite()することでパケットがインターネットから入ってきたように振る舞います。あとはカーネルがアプリケーションにデータを渡してくれます。

1
2
3
4
5
6
7
8
9
// サーバからデータ受信
int datalen;
if ((datalen = read(sockfd, data, sizeof(data))) <= 0) {
perror("read sockfd");
} else {
// アプリケーションに送信
if (write(tunfd, data, datalen) < 0)
perror("write tunfd");
}

動作確認

この図は、作ったVPNを使ってYoutubeで4K動画を再生中に、iftopeno1で開いてみた様子です。確かにVPNサーバとしか通信してませんね。速度もそこそこ出ているようです。レイテンシも気になるほどではありませんでした。

これはVPNありなしのときそれぞれのtracerouteの結果です。住所がバレるので少し隠してありますが、家で契約してるプロバイダを経由していないことがわかります。

1
2
3
4
5
6
7
8
9
# VPNあり
$ sudo traceroute -i tunclient 1.1.1.1 -4 -I
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
1 v182-58-392-21.fswu.static.cnode.io <- ConohaのVPS(VPNサーバ)
2 unused-133-54-39-045.interq.or.jp <- Conohaが契約してるプロバイダ
3 unused-133-54-39-001.interq.or.jp
4 unused-133-54-39-082.interq.or.jp
5 32.1.63.23
6 one.one.one.one
1
2
3
4
5
6
7
8
9
10
11
# VPNなし
$ sudo traceroute -i eno1 1.1.1.1 -4 -I
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
1 buffalo.setup <- 家のルータ
2 tokyo00-n000.flets.iij.net <- プロバイダ入口
3 TOKYO00-NTTeast1.flets.iij.net
4 * * *
5 * * *
6 * * tky001ix11.IIJ.Net <- プロバイダ出口
7 23.13.45.92
8 one.one.one.one

おわりに

読んでいただきありがとうございます。この記事ではパケットの流れに沿ってVPNをLinuxとC言語で自作しました。興味のある方はVPNサーバを構築してみるのはいかがでしょうか?

参考文献

脚注