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を見つけます。
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
{
'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アドレスくれ、という要求。
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~~
だったりします。理由は後述。
<?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して控えておく。
<?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を取得します。
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 });
}
<?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:1
なservice
を探す。再帰で。
この構造はどういう仕様なのかnekobatoは知らない。どう辿ったら必ずcontrolURL
が手に入るのか、誰か教えてほしい。
このcontrolURL
と前述のLOCATION
を組み合わせ、http://[Root Address]:[Port]/[controlURL]
から、Port Mappingやその他様々な操作が可能になる。
ちなみに、WANIPConnection
なのかWANPPPConnection
なのかは機種によって違いますが、ここで取得したものを使います。
なので上述したGlobal IP Addressを取得する場合もドキュメントからどっちなのか判断する必要があります。
Port Mapping
http://[Root Address]:[Port]/[controlURL]
へ、このようにSOAPで操作を要求する。
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としてコード内にあるのはちょっと見づらいバイト量。
<?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が返ってきます。
<?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'
<?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
実装の際に参考にしたコードです。