🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

NodeJSでSTUN(RFC5389)(の一部)を実装した

そもそも「STUNを実装する is 何」というところから整理しないとですが、しました。

ただしタイトルにもある通り、一部です。

GitHub - leader22/webrtc-stun: 100% TypeScript STUN implementation for WebRTC.

JavaScriptの先行実装はいろいろ見つかるけど、TypeScriptの実装はたぶん初。

`webrtc-stun`というパッケージ名で、npmからもインストールできます。

そもそもSTUNとは

ただこのRFCはあくまでSTUNそれ自体の説明書であって、ただの辞書です。

WebRTCにおいてどういう用途で使うとかはほぼ書いてなくて、これがWebRTCスタックを実装する時にいちばんつらいところらしい。

しかも別のRFCでしれっと拡張されてたりもするので、全容が把握できなくていい感じにコード書くのつらい。まじつらい。

WebRTCにおけるSTUNの用途

このあたりは、主にはICEとして別のRFCにまとまってる。

ともあれ、今回実装したのはあくまで、

  • RFC8445に書いてあるユースケースのうち
  • RFC5389に書いてある仕様だけで完結する部分
    • と余力のある限り他のRFC5389に書いてある部分

です。

で、具体的な用途として使えるのは、`icecandidate`の候補収集。

ICEの候補収集

SDPに載る`a=candididate`の行の話。

`RTCPeerConnection`の`iceServers`でオプションを渡すと、ICEの経路収集でそのSTUNサーバーが使われるようになる。

const pc = new RTCPeerConnection({
  sdpSemantics: 'unified-plan',
  // コレを指定すると
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

pc.createDataChannel('');
pc.createOffer()
  .then(s => pc.setLocalDescription(s));

// ココが多く発火する
pc.onicecandidate = ev => {
  if (ev.candidate !== null) {
    console.log(ev.candidate.candidate);
  } else {
    console.log(pc.localDescription.sdp);
  }
}

このオプションの有無で、`onicecandidate`が発火する回数が変わるし、指定してた場合は、`srflx`っていう`candidate-type`がついた経路が取得できるはず。

つまりブラウザの中にSTUNのクライアントの実装がいて、`setLocalDescription()`のタイミングで指定されたSTUNサーバーに問い合わせ(`BINDING-REQUEST`)をしてる。

STUNサーバーはそれを受けてあなたのIPやらポートはこうですという返事(`BINDING-RESPONSE`)をする、その返事の中で`XOR-MAPPED-ADDRESS`という属性を付与して返すのが決まり。

その結果、ブラウザがそれを`candidate`としてSDPに書くという流れ。

ちなみに、ブラウザではなくサーバーで動かすWebRTCの実装をする場合は、そもそも別のやりかたでグローバルなIPがわかってたりもするはずで、この用途においてはSTUNを使う必要がなかったりもする。

BINDING-REQUESTを送る

実装したSTUNを使って、実際にリクエストを送信するコードがこちら。

const dgram = require('dgram');
const { StunMessage } = require('webrtc-stun');

const socket = dgram.createSocket({ type: 'udp4' });

socket.on('message', msg => {
  const res = StunMessage.createBlank();

  // if msg is valid STUN message
  if (res.loadBuffer(msg)) {
    // if msg is BINDING_RESPONSE_SUCCESS
    if (res.isBindingResponseSuccess()) {
      const attr = res.getXorMappedAddressAttribute();
      // if msg includes attr
      if (attr) {
        console.log('RESPONSE', res);
        console.log(attr.ip, attr.port);
      }
    }
  }

  socket.close();
});

const req = StunMessage.createBindingRequest();
console.log('REQUEST', req);
socket.send(req.toBuffer(), 19302, 'stun.l.google.com');

やってることはコード見たらわかるくらい単純なので割愛。

今回はUDPでSTUNメッセージをやり取りするので、`dgram`を使うくらい。
WebRTC的にはTCPを使うこともあるらしい。

レスポンス例

インターネットに公開されてるSTUNサーバーはいくつもあって、上のコード例で使ってるGoogleの他にもいろいろある。(検索するとリストいっぱいでてくる)

`BINDING-REQUEST`を送った場合、`BINDING-RESPONSE`で返ってくる属性はこんな感じだった。

  • `stun.l.google.com:19302`
    • RFC5389: XOR-MAPPED-ADDRESS
  • `stun.webrtc.ecl.ntt.com:3478`
    • RFC5389: XOR-MAPPED-ADDRESS
    • RFC5389: MAPPED-ADDRESS
    • RFC5389: SOFTWARE
    • RFC5389: FINGERPRINT
    • RFC5780: RESPONSE-ORIGIN

`XOR-MAPPED-ADDRESS`だけあれば、この用途としては十分だということがわかる・・。
その他は返ってきても使わないので。

レスポンスには一応`SUCCESS`と`ERROR`があるけど、`SUCCESS`を返すに値しないリクエストは無視する!っていう挙動のSTUNサーバーも割といました。

BINDING-RESPONSEを返す

さっきのJavaScriptのコードの`iceServers`の部分を、自分で実装したSTUNに向ければもちろん試すことができる。

const dgram = require('dgram');
const { StunMessage } = require('webrtc-stun');

const socket = dgram.createSocket({ type: 'udp4' });

socket.on('message', (msg, rinfo) => {
  const req = StunMessage.createBlank();

  // if msg is valid STUN message
  if (req.loadBuffer(msg)) {
    // if STUN message has BINDING_REQUEST as its type
    if (req.isBindingRequest()) {
      console.log('REQUEST', req);

      const res = req
        .createBindingResponse(true)
        .setXorMappedAddressAttribute(rinfo);
      console.log('RESPONSE', res);
      socket.send(res.toBuffer(), rinfo.port, rinfo.address);
    }
  }
});

socket.bind(55555);

サーバーなのでポートを`bind()`してる。

UDPのメッセージを受けると`RemoteInfo`がついてるので、それを送り返すだけの簡単なお仕事。

ChromeFirefoxSafariも、この用途に関しては何の属性もつけてこない模様。

1つ注意するとすれば、`localhost`でSTUNサーバーを立ててしまうと、そもそも`host`のネットワークと一緒やんけ!ってことで無視されてSDPに載らないです。

その他の用途

RFC5389の範囲をこえた用途の一例 = RFC8445に書いてある例を書いとく。

だいたいが収集された`candidate`から最終的な経路が選ばれて接続を確立したあとに必要な用途で、Keep AliveとかConsent FreshnessとかConnectivity Checksとか定義されてる。

ちなみにこれらの挙動はテキトーなSDPをでっちあげてブラウザに渡すと確認できて、新たな属性にも出会えます・・。

属性の名前と、出自RFCはこんな感じ。

いやー奥が深まりますね!

STUN = RFC5389を実装するためには、そもそもの用途を規定するRFC8445を理解しないといけないという。

webrtc-stunについて

いちおうWebRTCにおけるSTUNの用途の必要なところだけを実装するつもりで、このリポジトリを`webrtc-stun`と名付けて開発してたんですが、上述の通りRFC5389だけではそれをカバーできてないです。

なのでAPIもこの構成がベストかどうかまだ判断しかねる状態で、他のRFCを実装してたらまたまるっと書き直すとかも普通にあるのでは・・?という感じ。

この先を追って実装するかはまだ未定ですが、なんか進捗あればまた書きます。

やるとしたらロードマップはこんな感じ。

  • [x] RFC5389: candidate収集に必要な部分
  • [ ] RFC5389: その他の属性
    • いま4/10だけ実装した
  • [ ] さらに他のRFCで拡張されてるもの
    • 全容は不明

割と長い道のりですよね・・!

ちなみにこのへん全部実装してあるのが、この間まで読んでたNodeRTCのSTUN実装です。

GitHub - nodertc/stun: Low-level Session Traversal Utilities for NAT (STUN) server

まじすごい。