DartでSTUNクライアント/サーバーを作ろう

STUNクライアント/STUNサーバーを実験的にDartで実装してみました。

得た知見をカキカキしました。

[コード]
https://github.com/kyorohiro/tetorica


# なぜなにSTUN 


![s_cover.jpg](https://qiita-image-store.s3.amazonaws.com/0/54192/1a35ff1f-ffb5-5526-a569-1550724f4495.jpeg)


本文は、"なぜなに Torrent"の　おまけです。"なぜなに Torrent"と同様に、実際にSTUN Server と STUN Client を実装して得た知見やノウハウを元に、アレコレP2Pについて解説してきます。

(※) 私が実装したもの
  https://github.com/kyorohiro/tetorica


# はじめに

なぜ P2Pなのか?

### Webアプリはお金がかかる
私たちは現在、さまざまなWebサービスを利用しています。そのほとんどが無料です。スマートフォンから地図を開いたり。ソーシャルネットワークで友人と連絡したり。Google検索して、調べ物をしたり。Youtubeで動画を見たり。

しかし、無料で利用していますが、その運営費は巨額です。

例えば、AWS上で動画配信サービスを展開しようとした場合を考えみましょう。
(ref https://aws.amazon.com/jp/cdp/cdn/)

320x180、10minで80MBくらいのデータサイズになります。ユーザー数が1000人いたとして、各ユーザーが、毎月60コンテンツ消費すると、5-6万円くらい、サーバー代がかかります。

いけそうな感じですが、ユーザー数が、10000人くらいまで増えると、50-60万円と、結構な値段になります。良いコンテンツで利用率が上がると、さらに...となります。


Webサービスを運営するには、資金を調達する必要があります。　出資していくれる方がいれば、それでも良いし。広告でもよいし。フリー・プレミアムモデルでも良いです。
うーん、どうでしょうか。厳しいなぁとい思う方もいるでしょう。



### P2Pで実現してみては?

解決方法の一つとして、P2Pが有ります。サーバーの負荷を利用者に分担することで、サーバーへの負荷を減らす事がでます。
中には、ほとんど、運営側のコストが掛からない、サービスも存在します。

たとえば、BitTorrent というファイル共有サービスはその一つです。
利用者が増えるほど、ファイルが配信される性能は上がります。

BitTorrentでは、ファイルをダウンロードしてもらう人が、データを配信する側に加わることで、
サーバーの配信コストを減らしています。

P2Pにより、このレベルまで最適化が進むと、配信者は企業という体裁を持たなくとも、
個人でもスケール可能なサービスを展開できます。




### STUN は、重要な役割を担っている

本書では、その中でも STUN について紹介します。STUNはP2Pアプリでも、P2Pを利用してユーザーになんらかのサービスを提供するようなものではありません。P2Pサービスの中の要素技術のひとつです。

有名なところでは、WebRTCという、リアルタイムコミュニケーションアプリを作るためのフレームワークなどで利用されています。たとえば、WebRTCはファイルを交換したり、ビデオチャットをしたり、といった事がサーバーを経由しないで実現する事ができます。

この、サーバーを経由しないで、ネットワーク上でユーザー同士が繋がるには、さまざまな障壁があります。
その障壁を越えるためには、ユーザーの端末の状態や情報を知る必要があります。
この、ユーザーの端末の状態を知るための要素技術がSTUNです。


# サンプルコードはDartを利用しています。

本書では、実際にコードを書いています。このコードには、 Dart を利用する事にしました。Dartは、現時点において、もっとも優れたプログラム言語です。C系、Java系の流れを汲みつつ、関数型的な書き方も柔軟にできます。

C、Java系の流れを組んでいるため、多くの人が読むことができるでしょう。関数型の流れを組んだ、完結なプログラムを書くことができます。

また、多くの環境で動作します。ブラウザー、ChromeOS、 Android、 iOS 、 Mac、Windows、Linux、Raspberry Pi 上で動作します。
きっと、あなたの環境でも動作する事でしょう。


# 試したい方へ

STUNを利用するには、IPアドレスが2つ必要です。　うーん、難しいですね。
VPSを借りるのが良いでしょう。

Serversman ( http://dream.jp/vps/ )、DigitalOcean( https://www.digitalocean.com/ )を利用すると良いでしょう。


### ServersMan@VPSを利用する場合

2016/3/12 現在に試してもので、将来的に保証されるものではありません。

##### 1. ServersManからStandard Plan を契約する。
###### 1.1. http://dream.jp/vps/
###### 1.2. standard plan を選択 (934円 per 月)
###### 1.3. ubuntu 64bit のOSを選んでください。 
##### 2. ログインする
###### 2.1. $ssh root@(your ip) -p (your port)
##### 3. 色々インストールする
###### 3.1 $apt-get update
###### 3.2 $apt-get upgrade
###### 3.2 $apt-get install git
###### 3.3 $apt-get install emacs
##### 4. Dartをインストール
https://www.dartlang.org/downloads/linux.html
###### 3.4 $apt-get update
###### 3.5 $apt-get install apt-transport-https
###### 3.6 $sh -c 'curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -'
###### 3.7 $sh -c 'curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list'
###### 3.8 $apt-get update
###### 3.9 $apt-get install dart
###### 3.10 $echo 'export PATH="$PATH:/usr/lib/dart/bin"' >> ~/.profile
###### 3.11 $source ~/.profile



# STUNとは

### インターネット上の端末はIPアドレスを持っている
インターネットに繋がっている端末には、すべてIPアドレスが振られています。このIPアドレスをもとに、相手の端末に接続する事ができます。

GoogleのWebページは、"www.google.jp"というアドレスをもっていますね。GoogleのWebページにアクセスすると、ブラウザーの上のほうに表示されています。

```
$nslookup www.google.jp
Non-authoritative answer:
Name:	www.google.jp
Address: 216.58.197.3
```

この、"216.58.197.3"というIPアドレスを、ブラウザーに入力してみてください。
GoogleのWebページが表示されるはずです。

インターネットの凄いところは、このIPアドレスさえわかれば、世界中のコンピュータに接続できるという事です。
もちろん、私たち利用者のコンピュータにも接続ができます。


### ほとんどのユーザーは、利用している時だけIPが配布されている

　しかし、さまざまな、経緯から、IPアドレスはサーバー側にしか所持していません。もちろん、ネットワークゲームをハードに利用するために、プロバイダーとIPアドレスを所持する契約をしている人や、IPアドレスを豊富に所持しているプロバイダーを利用しているユーザーは、IPを持っています。

良く持ちいられる話としては、現在、もっとも利用されているIPv4が、枯渇しているのが理由です。
IPv4は、2の32乗=43億のIPアドレスしか管理できません。
一見、大きな数字ですが、地球の人口はすでに、70億をこえていまから、一人にひとつのIPを割り振る事ができない状態な訳です。

　このような背景から、他の端末からアクセスされる端末には、固定のIPアドレスを割り振りますが、他の端末からアクセスされる事が発生しにくい端末には、利用する時だけIPを割り当てるという事が行われています。


### P2P接続は考慮されていない場合がほとんど。
 利用時に割り振られている、IPアドレスを調べ、相手に伝える事ができれば、P2Pアプリを作成できます。固定IPを持つサーバーでなくても、サーバーが提供するようなサービスを作成する事ができます。サーバー以上に、P2P独自のサーバーでは実現できない、より優れたサービスを提供する事もできます。

 しかし、残念な事にこの割り振られIPが、一時的なものであったり、複数の端末で共有しているものだったりして、P2Pアプリとして利用できない場合があります。
 もちろん、企業などてはセキュリティーを確保するたるにも、P2Pとして利用できないように制限をかけていたりする事もありのます。
 
 
 試しに、自分の端末に割り振られているIPをチェックしてみましょう。
 
```
$ifconfig
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
	options=3<RXCSUM,TXCSUM>
	inet6 ::1 prefixlen 128 
	inet 127.0.0.1 netmask 0xff000000 
	inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1 
	nd6 options=1<PERFORMNUD>
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
	options=10b<RXCSUM,TXCSUM,VLAN_HWTAGGING,AV>
	ether 10:dd:b1:d1:b1:37 
	inet6 fe80::12dd:b1ff:fed1:b137%en0 prefixlen 64 scopeid 0x4 
	inet 192.168.10.101 netmask 0xffffff00 broadcast 192.168.10.255
	nd6 options=1<PERFORMNUD>
	media: autoselect (100baseTX <full-duplex,flow-control>)
	status: active
```

といった、情報が表示されます。"127.0.0.1" "192.168.10.101"が割れ振られているIPv4です。残念ながら、グローバルに固定なIPではないはずです。
"10." "192." "172." "168."で始まるIPは、プライベートアドレスと言われているものです。ご家庭のインターネットに接続する機器(ルータ)が、あなたの端末に割り振っているIPです。

一時的にせよ、固定にせよ、実際に割り振られているIPはルーターが知っています。　ルーターから、IPアドレスを教えてもらわないと、P2Pアプリを実現できません。



### STUN を利用して、これらの状態を知る事ができる。

　本書では、STUNを利用する方法を紹介します。姉妹本である　"なぜなに Torrent" では UPnP Port Mapという方法を紹介しました。詳しくはそちらをみて欲しいのですが、軽く説明すると。

　ルータにどのようなIPが設定されているかを聞くための方式がま、UPnPという仕様で定められています。実際のこの仕様にそってルータに問い合わせる事で、IPとPortを知る事ができます。
 
 ただ、UPnP Portmap 自体をサポートとしていない場合や、ルータもIPアドレスを知らない場合があります。そのような場合、STUNを利用すると上手くいく事があります。


# Nat越えしてみよう

　実際にNat越えしてみましょう。徐々に拡張しながら、STUNへ近づけていきます。
 
 
## [1] サーバーに一時的に配布されたIPとPortを教えてもらう

　一時的に配布されているにはどうすれば良いでしょうか?　「実際にサーバーに接続してみて、そのサーバーに、一時的に配布されているIPアドレスとPortが何か教えてもらう。」というのが、STUNの基本的な戦略です。

　インターネットに利用している端末は、IPで管理されているのでした。我々はサーバーのIPアドレスを知っているからサーバーへ接続できます。どうように、サーバーも利用者のIPアドレスを知っているから、データを配信できるのです。
 このサーバーが認識している、IPアドレスを教えてもらいます。

#### Dartで書いてみよう

###### Server側のコード
```
import 'dart:io';
import 'dart:convert';

main(List<String> args) async {
  String svAddr = args[0];
  int svPort = int.parse(args[1]);
  startUDPServer(svAddr, svPort);
  startTCPServer(svAddr, svPort);
}

startUDPServer(String svAddr, int svPort) async {
  RawDatagramSocket socket = await RawDatagramSocket.bind(svAddr, svPort, reuseAddress: true);
  socket.listen((RawSocketEvent event) {
    if (event == RawSocketEvent.READ) {
      Datagram dg = socket.receive();
      String content = "${dg.address.address},${dg.port}\n";
      socket.send(UTF8.encode(content), dg.address, dg.port);
    }
  });
}

startTCPServer(String host, int port) async {
  ServerSocket server = await ServerSocket.bind(host, port);
  server.listen((Socket socket) {
    String content = "${socket.remoteAddress.address},${socket.remotePort}\n";
    socket.add(UTF8.encode(content));
  });
}
```

これは、サーバーにアクセスしてきた、端末のIPアドレスとPORT番号を返すプログラムです。30行程度ですが、十分機能します。

###### Client側のコード
```
import 'dart:io';
import 'dart:convert';

main(List<String> args) async {
  String clAddr = args[0];
  int clPort = int.parse(args[1]);
  String svAddr = args[2];
  int svPort = int.parse(args[3]);

  startUDPClient(clAddr, clPort, svAddr, svPort);
  startTCPClient(svAddr, svPort);
}

startUDPClient(String clAddr, int clPort, String svAddr, int svPort) async {
  RawDatagramSocket socket = await RawDatagramSocket.bind(clAddr, clPort, reuseAddress: true);
  socket.listen((RawSocketEvent event) {
    if (event == RawSocketEvent.READ) {
      Datagram dg = socket.receive();
      print("--");
      print("  [receive udp] ${dg.address.address} ${dg.port}");
      print("  ${UTF8.decode(dg.data,allowMalformed:true)}");
      print("--");
    }
  });
  socket.send(UTF8.encode("test"), new InternetAddress(svAddr), svPort);
}

startTCPClient(String svAddr, int svPort) async {
  Socket socket = await Socket.connect(svAddr, svPort);
  socket.listen((List<int> data) {
    print("--");
    print("  [receive tcp]");
    print("  ${UTF8.decode(data,allowMalformed:true)}");
    print("--");
  });
  socket.add(UTF8.encode(""));
}
```

client側のコードも30行程度ですね!!



#### TCP よりも UDPの方がP2Pに向いている

はい、UDPもTCPもIPアドレスを特定する事ができます。しかし、TCPだと、どのようなPort番号が設定されているかを確認する方法がないですね。

```
RawDatagramSocket socket = await RawDatagramSocket.bind(clAddr, clPort, reuseAddress: true);
socket.send(UTF8.encode("test"), new InternetAddress(svAddr), svPort);
```
```
  Socket socket = await Socket.connect(svAddr, svPort);
  socket.add(UTF8.encode(""));
```

UDPで、サーバーに接続する場合は、サーバーとして動作しているSocketを利用して接続できます。しかし、TCPでは、サーバーに接続する場合は、サーバーへ接続専用のSocketを利用しています。

このため、TCPは、サーバーとして待ち受けしているSocketのIPとPortが実際に何を指しているかを、判定できません。

P2Pアプリを実現する場合、UDPを使用した方が、より高い確率でP2P接続する事ができるようになります。





## [2] 他のサーバーからもアクセスしてもらう

　実際に利用してみると、[1]の方法では上手くいかない場合があります。上手くいかなかったあなた。おめでとうございます。自分で作成した仕組みを色々試していると、上手くいかないものです。 上手くいかないといのは、改善のチャンスですね。ついていますね。ルーターによっては、一時的に配布されるIPが毎回変わったり。Port番号の対応付け方法が、毎回変わる場合があります。

#### 一時的なIPが変わる場合でも、P2P接続できる
　それでも、相手を選べば、P2P接続可能です。自分自信には接続してもらえなくても、自分から接続しに行けば、つながります。
 通信相手が、Nat越え可能な場合は、こちらから接続しに行くという方法が残されています。
 
<table>
<tr>
<td></td><td>Nat越えできる</td><td>Nat越えできない</td>
</tr>
<tr>
<td>Nat越えできる</td><td>P2P接続できる</td><td>P2P接続できる</td>
</tr>
<td>Nat越えできない</td><td>P2P接続できる</td><td>P2P接続できない</td>
</table>


 

つまり、その端末のIPの状態がわかると、効率良くP2P接続ができます。



#### 前もって試しておこう!!

　STUNでは、別IP、別PORTから、接続する機能を持っています。前もってIPの状態を調べておくという訳ですね。

###### Server側

```
import 'dart:io';
import 'dart:convert';

main(List<String> args) async {
  String primaryAddr = args[0];
  int primaryPort = int.parse(args[1]);
  String secondaryAddr = args[2];
  int secondaryPort = int.parse(args[3]);
  startUDPServer(primaryAddr, primaryPort, secondaryAddr, secondaryPort);
}

startUDPServer(String primaryAddr, int primaryPort, String secondaryAddr, int secondaryPort) async {
  RawDatagramSocket ppSocket = await RawDatagramSocket.bind(primaryAddr, primaryPort, reuseAddress: true);
  RawDatagramSocket psSocket = await RawDatagramSocket.bind(primaryAddr, secondaryPort, reuseAddress: true);
  RawDatagramSocket spSocket = await RawDatagramSocket.bind(secondaryAddr, primaryPort, reuseAddress: true);
  RawDatagramSocket ssSocket = await RawDatagramSocket.bind(secondaryAddr, secondaryPort, reuseAddress: true);
  Map sockets = {"PP": ppSocket, "PS": psSocket, "SP": spSocket, "SS": ssSocket};
  ppSocket.listen((RawSocketEvent event) {
    if (event == RawSocketEvent.READ) {
      try {
        Datagram dg = ppSocket.receive();
        String request = UTF8.decode(dg.data);
        String content = "${dg.address.address},${dg.port}\n";
        print("udp: ${request}");
        RawDatagramSocket socket = sockets[request];
        socket.send(UTF8.encode(content), dg.address, dg.port);
      } catch (e) {}
    }
  });
}
```

###### Client側

```
import 'dart:io';
import 'dart:convert';

main(List<String> args) async {
  String clAddr = args[0];
  int clPort = int.parse(args[1]);
  String svAddr = args[2];
  int svPort = int.parse(args[3]);
  String type = args[4];
  startUDPClient(clAddr, clPort, svAddr, svPort, type);
}

startUDPClient(String clAddr, int clPort, String svAddr, int svPort, String type) async {
  RawDatagramSocket socket = await RawDatagramSocket.bind(clAddr, clPort, reuseAddress: true);
  socket.listen((RawSocketEvent event) {
    if (event == RawSocketEvent.READ) {
      Datagram dg = socket.receive();
      print("--");
      print("  [receive udp] ${dg.address.address} ${dg.port}");
      print("  ${UTF8.decode(dg.data,allowMalformed:true)}");
      print("--");
    }
  });
  print("##send ${type}");
  socket.send(UTF8.encode("${type}"), new InternetAddress(svAddr), svPort);
}
```

どうでしょうか。30行程度のコードでした。



#### STUN の分類方法

STUNでは、もう少し細かく分類してくれます。

<table>
<tr>
<td>\</td><td>PP</td><td>PS</td><td>SP</td><td>SS</td><td></td>
</tr>
<tr>
<td>UDP Blocked</td><td>x</td><td>-</td><td>-</td><td>-</td><td></td>
</tr>
<tr>
<td>Open Internet</td><td>o</td><td>-</td><td>-</td><td>o</td><td>ifconfigのIPとPORTがSTUNサーバーから受け取ったIPが同じ</td>
</tr>
<tr>
<td>Symmetric UDP Firewall</td><td>o</td><td>-</td><td>-</td><td>x</td><td>ifconfigのIPとPORTがSTUNサーバーから受け取ったIPが同じ</td>
</tr>

<tr>
<td>Full Cone</td><td>o</td><td>-</td><td>-</td><td>o</td><td>ifconfigのIPとSTUNサーバーから受け取ったIPとPORTが違う</td>
</tr>

<tr>
<td>Symmetric NAT</td><td>o</td><td>-</td><td>-</td><td>x</td><td>ifconfigのIPとSTUNサーバーから受け取ったIPとPORTが違う。 複数回試しても、異なるIPとPORTが返る</td>
</tr>

<tr>
<td>Restricted</td><td>o</td><td>o</td><td>-</td><td>x</td><td>ifconfigのIPとSTUNサーバーから受け取ったIPとPORTが違う。 複数回試しても、同じIPとPORTが返る</td>
</tr>

<tr>
<td>Port Restricted</td><td>o</td><td>x</td><td>-</td><td>x</td><td>ifconfigのIPとSTUNサーバーから受け取ったIPとPORTが違う。 複数回試しても、同じIPとPORTが返る</td>
</tr>
</table>

* PP : 接続したIPから返答が返る
* PS : 接続したIPから返答が返る。PORTが異なる
* SS : 接続したIPと異なるIPから返答が返る。PORTも異なる
* o  : 返信がきた
* x  : 返信がこない
* -  : 条件に含まない

 
といった感じです。サーバー機能を利用して待ち受けできるのは、"Open Internet" と "Full Cone"　のみとなります。


# 実装してみよう

「NATを越えてみよう」の章では、簡易の実装をしました。が、STUNの仕様にそってはいません。実装してみましょう。

以下のRFCを参考にしてみてください。

http://tools.ietf.org/html/rfc5389

http://tools.ietf.org/html/rfc5769

http://tools.ietf.org/html/rfc5780

http://tools.ietf.org/html/rfc3489


# あとがき


ここまで、読んでくれてドウモです。

STUNの仕組みは単純でしたね。「サーバー機能を持たせられない端末が存在する事を考慮してアレコレする必要があるんだなぁ。」「ちょっと面倒くさいなぁ」という事が解ったと思います。「だから、流行らないんだなぁ」とも考えているかも知れません。しかし、ちょっと工夫すれは、サーバーの運用コストが少なくなります。まぁ、人件費は上がるかもしれませんが。



　しかし、今まで、コストがかかりすぎて、Googleとか、Appleとか、Amazonとかインフラを持っている企業に対抗するサービスが作れないと思っていたあなた。スタートアップするお金がないあなた。
　もしかすると、対抗できるかも知れませんよ。もしも、P2Pサービスの展開に成功すると、今までコストだと思っていたものが、そのまま強さになります。
 
　
「特殊技能をもった人」は直ぐには育成できません。圧倒的な低コストで運用できるサービスは他の追随をゆるしません。


どうですか?　挑戦してみたくなりましたよね?
ではでは



# 次回

"なぜなに UPnP Portmap" "なぜなに TURN"を書きます。

# 補足

一応、Gitbookの方をメインに更新していく予定。

[なぜなにSTUN]
https://www.gitbook.com/book/kyorohiro/doc_stun/details

[なぜなにTorrent]
https://www.gitbook.com/book/kyorohiro/doc_hetimatorrent/details

[関連ソフト等]
* Coturn
https://github.com/coturn
* STUNTMAN
http://stunprotocol.org/
* RFC3489 Server/Client
https://sourceforge.net/projects/stun/
* WebRTC
https://webrtc.org/
* Bittorrent
http://www.bittorrent.com/