こんにちは、藤村と申します。
突然ですが、自宅のPCでWebサーバーを立てて、外のネットワークからアクセスしようとして繋がらなかった経験はありませんか。
自宅のPCには、原則として外から接続できません。
ところが世の中を見渡すと、ビデオ通話やオンラインゲームの中には、通信データをサーバーで中継せず、端末同士が直接やり取りするものがあります。
このように、端末同士が対等な立場で通信する方式を P2P(peer-to-peer) と呼びます。
外から接続できないはずの端末同士がなぜ繋がるのでしょうか。
ここで使われる技術が ホールパンチング(hole punching) です。
なお、ホールパンチングにはUDPベースのものとTCPベースのものがあり、本記事で扱うのはUDPホールパンチングです。
この記事では、その仕組みを示した上で、TypeScriptを使って簡単な実装を行い、実際に異なるネットワーク配下の2台を直結させてみます。
前提: NATは「外向きの穴」しか開けない
以下では、ポート転送などの静的設定がない一般的なIPv4/NAPT環境を前提にします。
自宅のPCが持っているのは 192.168.x.x のようなプライベートIPアドレスで、公共のインターネット上では直接ルーティングされません。
そこでルーターが NAT(正確にはNAPT) として動作し、「内側のIP:ポート」と「グローバルIP:ポート」の対応づけ(マッピング)を管理して、パケットのアドレスを変換します。
このマッピングは、内側から外側への通信が発生したときに作られます。
このとき、同じ内側のIP:ポートから別の宛先へ送ったときにマッピングを再利用するかどうか(マッピング方式)と、外から届いたパケットを内側へ通すかどうかの条件(フィルタリング方式)は、いずれもNATの実装によって異なります。
本記事では、マッピングは宛先によらず再利用され(RFC 4787でいうendpoint-independent mapping)、フィルタリングは「事前に送信した相手のIP:ポートからのパケットだけを通す」(同 address and port-dependent filtering)という組み合わせを例に進めます。
後述の通り、宛先ごとにマッピングを作り直すNAT(endpoint-dependent mapping)では、この記事の手法は成立しません。
つまりNATは、内側からの通信をきっかけに「穴」を開け、その穴を通る戻りパケットだけを許可する装置です。
ここでいう「穴」は、マッピングと、それに対応する受信許可(フィルタリング状態)をまとめた比喩です。
外部からの一方的な接続が不可能なのはこのためです。
であれば、双方がお互いに向かって、内側から撃ち合えばいいのではないでしょうか。
それがホールパンチングです。
ピアAがピアBに向けてパケットを送ると、ピアA側NATでは既存のマッピングが再利用され、あわせてピアBからのパケットを受け入れるフィルタリング状態が作られます。
ピアB側でも同じことが起きれば、以降は双方のパケットが、既存のマッピング宛てでありフィルタリング条件も満たすため、内側のピアまで転送されます。
NATは「応答かどうか」を意味的に判断しているわけではなく、マッピングとフィルタリング条件を機械的に照合しているだけなので、こちらから撃った相手からのパケットであれば通るわけです。
問題: お互いの「外から見えるアドレス」を知らない
ただし、実行するには1つ問題があります。
相手に向かって撃つには、相手のグローバルIP:ポートを知らなければならない。
そして厄介なことに、自分が外からどのIP:ポートに見えているかは、自分でも分かりません。
NATがどのポートを割り当てるかは、パケットが実際に外へ出て初めて決まるからです。
そこで第三者の出番です。
両者から到達できる場所(グローバルIPを持つサーバー)を1台用意し、こう動かします。
- ピアAとピアBがそれぞれサーバーにUDPパケットを送る
- サーバーは、受信パケットの送信元アドレスを見る。これが各ピアの 観測アドレス(外から見えるIP:ポート) そのものである
- サーバーはピアAにピアBの観測アドレスを、ピアBにピアAの観測アドレスを教える
この仲介役を ランデブーサーバー(またはシグナリングサーバー)と呼びます。
このサーバーは住所交換しかしません。
ピア同士の通信データは一切中継しないので、直結後のデータもサーバーを経由しません。
実装する
理屈が分かったので作ります。
TypeScriptで、ランタイムはNode.js、UDP部分は標準の dgram モジュールのみで、実行時の外部依存はゼロです。
筆者はサーバー側をVPS(Node.js v22.23.1)で node server.ts として、ピア側を手元のWindows(Node.js v20)で npx tsx peer.ts として実行しました。
Node.jsが単体でTypeScriptを実行できる(型注釈を除去して実行する。型チェックは行われません)のは、v22.18.0で既定になった機能です。
v22.6.0〜v22.17.xでは --experimental-strip-types フラグを付ければ実行でき、それより前のバージョン(上のピア側のv20など)では tsx などを使ってください。
ランデブーサーバー
import dgram from "node:dgram";
const TOKEN = "aki-no-ta-no";
const PORT = Number(process.argv[2] ?? 40000);
const sock = dgram.createSocket("udp4");
const sessions = new Map<string, { address: string; port: number }>();
sock.on("error", (err) => {
console.error("[socket error]", err.message);
sock.close();
});
sock.on("message", (data, rinfo) => {
const parts = data.toString("ascii").trim().split(/\s+/);
if (parts.length !== 3) return; // 形式不正は黙って捨てる
const [cmd, token, sessionId] = parts;
if (cmd !== "REGISTER" || token !== TOKEN) return; // 合言葉不一致も黙って捨てる
console.log(`[server] REGISTER from ${rinfo.address}:${rinfo.port} (session=${sessionId})`);
const existing = sessions.get(sessionId);
if (existing && existing.address === rinfo.address && existing.port === rinfo.port) {
return; // 同じピアの重複REGISTER。2人目として扱わない
}
if (!existing) {
sessions.set(sessionId, { address: rinfo.address, port: rinfo.port });
} else {
sessions.delete(sessionId);
sock.send(`PEER ${rinfo.address} ${rinfo.port}`, existing.port, existing.address);
sock.send(`PEER ${existing.address} ${existing.port}`, rinfo.port, rinfo.address);
console.log(`[server] paired ${existing.address}:${existing.port} <-> ${rinfo.address}:${rinfo.port}`);
}
});
sock.bind(PORT, () => console.log(`[server] listening on UDP :${PORT}`));
やっていることは単純で、REGISTER パケットを受け取り、同じセッションIDのピアが2人揃ったら、message イベントの rinfo(受信パケットの送信元アドレス=観測アドレス)を互いに送り返すだけです。
合言葉(TOKEN)が合致しないパケットと形式不正のパケットには、応答も返さず黙って捨てます(これは無関係なパケットによる影響を減らすための簡易的な制限で、認証機構ではありません)。
また、同一の送信元からの重複した REGISTER は2人目として扱わず無視します。
このガードがないと、パケットの重複や再送で、ピアが自分自身とペアリングされてしまいます。
なお、このガードが効くのはペアリング成立前だけです。
成立後は該当セッションを削除しているため、遅れて届いた重複パケットは新しいセッションの1人目として扱われます(記事の範囲では実害は出ませんが、実用化する場合は成立済みセッションIDの記録が必要です)。
ピア
import dgram from "node:dgram";
import { isIP } from "node:net";
const TOKEN = "aki-no-ta-no";
const [serverHost, serverPortStr, sessionId] = process.argv.slice(2);
const serverPort = Number(serverPortStr);
const sock = dgram.createSocket("udp4");
let peerAddr: { address: string; port: number } | null = null;
let connected = false;
sock.bind(0, () => {
const addr = sock.address();
console.log(`[peer] local addr = ${addr.address}:${addr.port}`);
sock.send(`REGISTER ${TOKEN} ${sessionId}`, serverPort, serverHost);
console.log(`[peer] registered to ${serverHost}:${serverPort}, waiting for peer...`);
});
sock.on("error", (err) => {
console.error("[socket error]", err.message);
sock.close();
});
sock.on("message", (data, rinfo) => {
// --- まだ相手のアドレスを知らない: サーバーからのPEER通知を待つ ---
if (!peerAddr) {
if (rinfo.address !== serverHost || rinfo.port !== serverPort) return;
const parts = data.toString("ascii").trim().split(/\s+/);
const port = Number(parts[2]);
if (
parts.length === 3 && parts[0] === "PEER" &&
isIP(parts[1]) === 4 && // 受信したアドレスは形式を検証してから使う
Number.isInteger(port) && port >= 1 && port <= 65535
) {
peerAddr = { address: parts[1], port };
console.log(`[peer] got peer address: ${peerAddr.address}:${peerAddr.port}`);
// --- 相手に向けて撃ち続ける(これが「穴を開ける」動作) ---
// 受信はmessageイベントで非同期に処理されるため、受信用スレッドは不要。
// setIntervalによる送信と受信リスナーが同じイベントループ上で並行する。
const MAX_ATTEMPTS = 30; // 未検証の宛先へ永久に送り続けない
let n = 0;
const puncher = setInterval(() => {
if (connected || n >= MAX_ATTEMPTS) {
clearInterval(puncher);
if (!connected) { console.error("[peer] punch timeout"); sock.close(); }
return;
}
sock.send(`PING ${n}`, peerAddr!.port, peerAddr!.address);
n++;
}, 500);
}
return;
}
// --- 相手以外からのパケットは無視 ---
if (rinfo.address !== peerAddr.address || rinfo.port !== peerAddr.port) return;
if (!connected) {
connected = true;
console.log(`[peer] HOLE PUNCHED! direct packet from ${rinfo.address}:${rinfo.port}`);
}
const msg = data.toString("ascii");
console.log(`[peer] recv: ${msg}`);
if (msg.startsWith("PING")) {
sock.send(`PONG (echo of ${msg})`, rinfo.port, rinfo.address);
}
});
実行時の引数は、ランデブーサーバーのアドレス、ポート、セッションID(相手と合わせる文字列)の3つです。
一点だけ注意として、サーバーのアドレスはIPアドレスで指定してください。
このコードは応答パケットの送信元と引数の値を単純比較しているため、ホスト名を渡すと(送信元にはIPアドレスが入るので)一致せず、サーバーからの通知を黙って無視してしまいます。
なお、説明を簡潔にするため、サーバー・ピアとも実行引数の検証は省いています。
また、簡略化のため、REGISTERとPEER通知の再送とタイムアウト、サーバー側の登録の有効期限は実装していません。
反応がない場合は、サーバーと両ピアをすべて終了し、新しいセッションIDで最初からやり直してください。
同じセッションIDを使い回すと、片方の古い登録がサーバーに残っている場合に、再起動したピアが(起動のたびに別のポートで登録されるため)自分自身の古い登録とペアリングされてしまうからです。
なお、穴が開いた後もソケットを開いたままにしているため、ピアのプロセスは自動終了しません。
確認できたら Ctrl+C で終了してください。
なぜ一発ではなく撃ち続けるのか
このコードでは、setInterval でパケットを繰り返し送り続けています。
1発でいいのでは、と思うかもしれませんが、初弾は相手側NATで破棄されることがあるため、1回の送信では接続を確認できるとは限りません。
双方が撃ち始めた直後を時系列で追うと、こうなります。
- ピアAが先に
PINGを送る。この送信でピアA側NATに「ピアBからの受信を許可する穴」が開く(マッピング自体はREGISTER送信時に作成済みで、ここではそれが再利用されます)。しかしピアB側NATにはまだピアAからの受信許可がないので、このパケットはピアB側NATで破棄される - 続いてピアBが
PINGを送る。ピアB側NATにピアAからの受信を許可する穴が開き、そしてこのパケットは すでに開いているピアA側の穴を通ってピアAに届く - 以降、双方の穴が開いた状態なので、パケットは両方向に通る
つまり先に撃った側の最初のパケットは、届く必要がありません。
その送信自体が、自分側の穴を開けているからです。
それでも撃ち続けるのは、UDPには到達保証がなく、双方の開始タイミングにもずれがあるからです。
一定間隔で再送することでこれらを吸収しつつ、応答がなければ最大30回で打ち切ります。
なお、フィルタリングの緩いNATでは、先に撃った側の初弾がそのまま相手に届くこともあります。
離れた2つの回線での接続実験
環境は次の通りです。
- ランデブーサーバー: VPS(グローバルIP直付け)、UDP 40000番を実験中のみ開放
- ピアA: 筆者の自宅回線(ルーターのNAT配下)
- ピアB: 友人宅の回線(別のNAT配下)。友人にスクリプトを渡して、同じセッションIDで同時に実行してもらいました
なお、以下のログに登場する実在のグローバルIPアドレスは、すべてドキュメント用の例示アドレス(RFC 5737)に置き換えています。
VPSを 192.0.2.1、筆者側(ピアA)の観測アドレスを 198.51.100.1、友人側(ピアB)の観測アドレスを 203.0.113.1 としています(ログ中の 0.0.0.0 は実IPではなく、ソケットが全インターフェースで待ち受けていることを示す値です)。
筆者側(ピアA)の実行結果がこちらです。
PS C:\work\p2p> npx tsx peer.ts 192.0.2.1 40000 test-session-1
[peer] local addr = 0.0.0.0:52322
[peer] registered to 192.0.2.1:40000, waiting for peer...
[peer] got peer address: 203.0.113.1:33757
[peer] HOLE PUNCHED! direct packet from 203.0.113.1:33757
[peer] recv: PING 0
[peer] recv: PONG (echo of PING 0)
HOLE PUNCHED! の行で、届いたパケットの送信元が 203.0.113.1、つまりVPS(192.0.2.1)ではなく友人側の観測アドレスになっています。
サーバーは相手の住所を教えてくれただけで、その後のパケットは相手のNATから直接届いています。
なお、このログでは双方のPING 0がどちらも相手に届いています。
ほぼ同時に撃ち始めたため、双方の初弾が相手側NATへ到着するまでに、双方のNATで相手からの受信許可が作られていたものと考えられます。
ただし、今回のコードが確認するのは接続確立までです。
KEEPALIVE(接続を維持するための定期的な送信)は実装していません。
また、同じ内側のIP:ポートから別の宛先へ送ったときに同じマッピングを再利用しないNAT(endpoint-dependent mapping)の配下では、サーバーが観測した外部エンドポイントと、ピア宛てに送るときの外部エンドポイントが一致しないため、今回の単純な方式は成立しません。
この条件は経路上のすべてのNATに当てはまるので、家庭用ルーターの外側にもうひとつNATがあるCGNAT環境では、そちらの挙動も影響します。
今回の実験では成立しましたが、失敗するケースを筆者は検証していません。
実験する人向けの注意
インターネットにUDPポートを開ける以上、この実験で特に注意したい項目を挙げます。
一般的なサーバーセキュリティ対策を網羅するものではありません。
- ランデブーサーバーは実験中だけ起動し、VPS側のファイアウォール(OSのufw等と、クラウド側のセキュリティグループの両方)のポート開放も実験期間に限定する。サーバーは受け取ったセッションIDをそのまま保存し、有効期限も件数上限も設けていないため、第三者がセッションIDを変えながら送り続けるとメモリが増え続けます。この点でも、実験中だけ起動することが前提です
- 記事に掲載した
TOKENの値は公開情報なので、秘密として機能しません。実験する際は推測困難な値に変えて、相手とは別の経路で共有してください。セッションIDも同様に推測困難な英数字のランダム値にしてください。推測可能なIDでは、第三者が先に登録してペアリングを横取りできます - ピア側で受信データを信用しない。サーバーと相手以外からのパケットは無視し、受信したアドレスとポートは形式を検証してから送信先として使う(今回のコードには実装済み)。ただし検証しているのは「IPv4の書式であること」「ポートが1〜65535であること」だけで、アドレスの範囲は見ていない。信用できないサーバーを使う場合は、ループバックやマルチキャストなど、想定していない宛先に送らされないよう範囲も絞る必要がある
この実装にはピア間の認証と暗号化がありません。
経路の確立(ホールパンチング)に焦点を絞るため意図的に省いています。
本記事のコードを実用に発展させる場合は、穴が開いた後に認証と暗号化のレイヤーを載せる必要があります。
まとめ
「自宅のPCには外から繋がらない」のはNATが外向きの穴しか開けないからで、「それでもP2Pが繋がる」のは双方が互いに向かって外向きに撃つことで穴を開け合っているからでした。
繋がらないのはNATが悪いからではなく、NATの仕様がそう決まっているからで、仕様を正確に理解すれば、それを逆手に取って繋げられます。
個人的には、「届くこと」ではなく「送ること」自体に意味を持つ最初のパケットが、この技術の一番美しい部分だと思っています。
コードは全文掲載したものがすべてです。
VPSが1台と、別のネットワークにいる協力者が1人いれば追試できます。
本文で見た通り、この手法はどのNATの配下でも成立するとは限りません。
手元で「HOLE PUNCHED!」が出たなら、少なくとも今回のサーバー宛てとピア宛ての通信では、経路上のNATがホールパンチングに必要な形でマッピングを再利用できたことを確認できます。
ぜひ確かめてみてください。