Node.js
NatTraversal

P2P通信でNatを越える

P2Pを利用する通信に必要なNat TraversalをNode.jsでやっていく。

古来のP2Pアプリケーションを使う際は「ポートを空ける」という作業がありました。

まずポートが開かないとルーターの外と通信が出来ない。

そんなとき、P2Pアプリケーションは「開けたポートをここで設定してね!」という設定項目を用意していますが、

ナウなアプリは自分で開けます。

そんなナウいアプリがやっているのがNat Traversal。いわゆるNat越え。

Nat Traversal, ポート越えなどと言われます。

UDPの場合はUDP hole punchingと言うそうです。

以下、Nat超えって打つと「Nat越」とか「Nat超え」しか出てこないのでNat Traversalと呼びます。


これはBlockChain記事なのか

BlockChainのP2P通信部分を作る際にはNAT越えをする必要があるので、NAT越えはBlockChain。gethのコードにも含まれている。

https://qiita.com/erukiti/items/ec872816eb3a8415f0cd のめんどくさいP2P部分です。順番にやっていきましょう。


方法

ご自宅のルータがuPnPに対応していればNat越えは可能です。 企業のネットワークからはセキュリティ上不可能な場合がもっぱらです。企業ネットワーク内の方は、担当の人にPort Mappingしてもらいましょう。

ルータネットワークの中からSSDPでルータ機器を見つけ、SOAPでルータ機器のGlobal IP Addressを取得 & Port MappingすることでNat越えは完了します。


用語の説明


uPnP

Universal Plug and Playのことです。

詳しくはggってください。

大抵の家庭用ルータはuPnPに対応しており、デフォルトでONになっています。

おうちのプリンタがネットワークに繋いだだけで使えるようになるのはuPnPのおかげ。


SSDP

uPnPに対応した機器を見つけるための通信プロトコルです。

詳しくはgg


SOAP

古代の通信プロトコルです。そんなに古代でもないかも。REST以前の技術。

POSTでbodyにはXMLを入れて、レスポンスはXMLで返ってくる。

uPnPはみんなこれで通信する。


FireWall

知らない子ですね


手順 (by Node.js)

まず、UPnPの仕様に沿ってUPnPネットワーク上からrootdevice、つまりルーターのInternal IP Addressを見つけます。


discover-rootdevice.js

const { client } = require('node-ssdp');

module.exports = async function discoverRootDevice () {
let client = new ClientRect();

return new Promise((resolve, reject) => {
client.on('response', (headers) => {
// 一つ見つかったら返す
resolve(headers);
})

client.search('upnp:rootdevice');

// 3秒待って戻ってこなかったら諦める
setTimeout(reject, 3000);
});
}


SSDPプロトコルについては便利なnode-ssdpがあるのでそれを使います。

見つからなくても永遠に探し続けるので、3秒経ったら諦めています。

3秒という目安はNEOのコードに書いてあったのでそのまま拝借。

どうでもいいけどNEOのコードでは念入りに3回searchしてる。なんで...?

https://github.com/neo-project/neo/blob/master/neo/Network/UPnP.cs#L35-L37


res.js

{

'CACHE-CONTROL': 'max-age=120',
ST: 'upnp:rootdevice',
USN: 'uuid:xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx::upnp:rootdevice',
EXT: '',
LOCATION: 'http://192.168.0.1:[port]/hogehoge.xml',
...
}

ルーターが無事見つかると、こんなheadersが返ってきます。

このLOCATIONがrootdeviceが持つUPnPに関するドキュメントのURL。


ルーターのドキュメントのURLを見る


Global IP Addressを取得

Nat Traversalそのものではありませんが、P2Pでは相手側に自分のGlobal IP Addressを知らせる必要があります。

外部IP Addressを取得する方法は他にもあるものの、さきほど取得したrootdeviceのURLを利用する方法を記します。

POSTでbodyとheaders.SOAPACTIONに要求を書くことでルーターが応答してくれる。SOAPなのでbodyはXMLで書く。

下記は外部IPアドレスくれ、という要求。


get-external-ip-addr.js

const fs = require('fs');

const axios = require('axios');

module.exports = async function getExternalIpAddr(headers) {
return axios.post(headers.LOCATION, fs.readFileSync('./get-external-ip-addr.xml', { encoding: 'utf8' }), {
headers: {
SOAPACTION:
'urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress'
}
});
}


bodyのXMLはこんな感じ。

urn:schemas-upnp-org:service:WANIPConnection:1は場合によって~~WANIPConnection~~だったりします。理由は後述。


get-external-ip-addr.xml

<?xml version="1.0"?>

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddress xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"></u:GetExternalIPAddress>
</s:Body>
</s:Envelope>

SOAPのレスポンスはXMLで返ってくる。

NewExternalIPAddressが目的の外部IPアドレスなので、parseして控えておく。


response.xml

<?xml version="1.0"?>

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewExternalIPAddress>xxx.xxx.xxx.xxx</NewExternalIPAddress>
</u:GetExternalIPAddressResponse>
</s:Body>
</s:Envelope>

これでGlobal IP Addressの取得は完了。


Nat Traversal

Nat Traversalをしていきます。

前述で取得したルーターが持つドキュメントURLだけではPort Mapping操作ができないので、操作をするためのURLを取得します。


get-root-device-info.js

const axios = require('axios');

const xmlConverter = require('xml-js');

// url: ルーターが持つドキュメントURL
module.exports = async function getRootDeviceInfo (url) {
const res = await axios.get(url);
return xmlConverter.xml2json(res.data, { compact: true });
}



response.xml

<?xml version="1.0"?>

<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
...
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
<controlURL>/hoge/foo</controlURL>
<eventSubURL>/hoge/hoo</eventSubURL>
<SCPDURL>/Hogehoge.xml</SCPDURL>
</service>
</serviceList>

レスポンスとして返ってくるXMLは構造が複雑なので、urn:schemas-upnp-org:service:WANIPConnection:1もしくはurn:schemas-upnp-org:service:WANPPPConnection:1serviceを探す。再帰で。

この構造はどういう仕様なのかnekobatoは知らない。どう辿ったら必ずcontrolURLが手に入るのか、誰か教えてほしい。

このcontrolURLと前述のLOCATIONを組み合わせ、http://[Root Address]:[Port]/[controlURL]から、Port Mappingやその他様々な操作が可能になる。

ちなみに、WANIPConnectionなのかWANPPPConnectionなのかは機種によって違いますが、ここで取得したものを使います。

なので上述したGlobal IP Addressを取得する場合もドキュメントからどっちなのか判断する必要があります。


Port Mapping

http://[Root Address]:[Port]/[controlURL]へ、このようにSOAPで操作を要求する。


add-port-mapping.js

const ejs = require('ejs');

const internalIp = require('internal-ip');
const axios = require('axios');

module.exports = async function addPortMapping(url) {
const internalIpAddress = await internalIp.v4();
const template = await ejs.renderFile('./add-port-mapping.ejs',
{
// port numberは自由に設定して良い
port: 55962,
protocol: 'TCP',
localAddress: internalIpAddress,
description: 'test'
}
);
console.log(template);
return axios.post(url, template, {
headers: {
SOAPACTION:
'urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping'
}
});
}


今回templateにはXMLを少しシンタックスハイライトしてくれる & 変数を挿入したいという理由でejsを使っているけども、なんでも良いと思う。

ただStringとしてコード内にあるのはちょっと見づらいバイト量。


add-port-mapping.ejs

<?xml version="1.0"?>

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort><%= port %></NewExternalPort>
<NewProtocol><%= protocol %></NewProtocol>
<NewInternalPort><%= port %></NewInternalPort>
<NewInternalClient><%= localAddress %></NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription><%= description %></NewPortMappingDescription>
<NewLeaseDuration>0</NewLeaseDuration>
</u:AddPortMapping>
</s:Body>
</s:Envelope>

ちゃんとポートが開いたら開いた旨のXMLが返ってきます。


response.xml

<?xml version="1.0"?>

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"/>
</s:Body>
</s:Envelope>

これでNat Traversalは完了。以後取得したGlobal IP Address + MappingしたPortを相手に知らせることで、外から自分のPCへアクセスすることができるようになります。


開けたらちゃんと閉める

開けっ放しは良くないので、用が済んだら閉めましょう。

SOAPACTIONは'urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping'


delete-port-mapping.ejs

<?xml version="1.0"?>

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<m:DeletePortMapping xmlns:m="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort><%= port %></NewExternalPort>
<NewProtocol>TCP</NewProtocol>
</m:DeletePortMapping>
</s:Body>
</s:Envelope>


おわりに

というわけで、とても面倒くさい。

P2Pによる分散ネットワークを分散型のまま通信するならば、現状で最も確実な方法はNat Traversalです。

WebRTCはWebの技術によってP2P通信が可能になる、夢のある通信プロトコルですが、STUN, TURNなどの中央集権(権?)的なサーバーが必要になる問題があります。


ref

実装の際に参考にしたコードです。

https://github.com/neo-project/neo/blob/master/neo/Network/UPnP.cs