🛠️

WebRTCプロトコルスタックの相互接続試験に参加した話

2021/03/18に公開

はじめに

WebRTC には libwebrtc(Chrome/FF/Safari/etc...)以外にも Pion,aiortc,sipsorcery などの様々な実装が存在します。
基本的にこれらの実装は libwebrtc(要するにブラウザ) との疎通を目指して実装されており、libwebrtc 以外の実装同士での互換性はあまり重視されていませんでした。

それに対して sipsorcery の作者が pion のリポジトリの Discussion セクションで WebRTC 実装間での相互接続試験の実施を呼びかけ、WebRTC プロトコルスタック実装者たちがそれに参加し、相互接続試験が行われました。

現時点で参加している WebRTC 実装は以下です。

言語 リポジトリ
aiortc python https://github.com/aiortc/aiortc
Pion Go https://github.com/pion/webrtc
sipsorcery C# https://github.com/sipsorcery-org/sipsorcery
werift TypeScript (Node.js) https://github.com/shinyoshiaki/werift-webrtc

著者は werift の作者です。

相互接続試験は
https://github.com/sipsorcery/webrtc-echoes
のリポジトリで行われており、GithubActions によって master ブランチへの push 起因で自動的にテストが実行されています。
現在のテスト結果は次のようになっています

result

相互接続試験について

相互接続試験の仕様についてドキュメントが存在するので紹介します。
https://github.com/sipsorcery/webrtc-echoes/blob/master/doc/EchoTestSpecification.md

概要

WebRTC echo test の目的は、2 つのピア(Server Peer と Client Peer)の間でピア接続が確立されているかどうかを検証することです。

このテストの「echo」とは、Server Peer が受信したオーディオやビデオのパケットを Client Peer に返送する挙動のことです。この方法は、ブラウザを Client Peer としてテストを行うと、テストの成功を迅速に視覚的に示すことができる点で優れています。

シグナリング

このテストで行われる唯一のシグナリング操作は、Client Peer から Server Peer への HTTP POST リクエストです。Client は、サーバーに Offer SDP を送信し、サーバーは Answer SDP をレスポンスで返信します。

注:シグナリングの複雑さを軽減するためにこのようなシングルショットシグナリング手法を採用しています。シングルショットシグナリングを使用するには、Client Peer または Server Peer の少なくとも一方が、Vanilla ICE モードで動作し、ICE Candidate を SDP に含める必要があります。実際は、両方のピアが Vanilla ICE モードで動作するように設定されているのが望ましいです。

Server Peer

Server Peer に必要なフローは次のとおりです。

  • HTTP の POST リクエストを TCP ポート8080でリッスンする。HTTP サーバが POST リクエストをリッスンしなければならない URL は次のとおりです。
    • http://\*:8080/offer
  • Client からの POST リクエストのボディには、JSON でエンコードされたRTCSessionDescriptionInitオブジェクトが含まれます。
  • Offer SDP を受信すると、新しい「Peer Connection」を作成して、Offer を setRemoteDescription します
  • Answer SDP を生成し、HTTP POST レスポンスで JSON エンコードされたRTCSessionDescriptionInitオブジェクトとして返します。
  • PeerConnection の接続を行います。
  • PeerConnection で受信した RTP メディアを対向の Peer に送り返します。

Client Peer

Client Peer に必要なフローは次のとおりです。

  • 新しいPeer Connectionを作成し、Offer SDP を生成します。
  • Offer SDP を JSON エンコードされたRTCSessionDescriptionInitオブジェクトとして、HTTP POST リクエストでServer Peerに送信します。
  • HTTP POST レスポンスは、JSON エンコードされたRTCSessionDescriptionInitオブジェクトとしての Answer SDP になります。
  • PeerConnectionに Answer SDP を setRemoteDescription します。
  • PeerConnectionの初期化を実行します。
  • オプションとして、音声や動画をサーバーに送信します。
  • Client がPeerConnectionの接続に成功したと判断したら、接続を終了して終了コード0を返します。接続に失敗したり、タイムアウトした場合、Client は終了コード1を返します。
    • (接続成功の基準は明確に決まっておらず、実装によっては DTLS の接続成功を基準にしていたり、RTP が Echo されることを基準と、したりしている)

相互接続試験のテストプログラム

著者は werift の作者なので、werift のテストプログラムを例に出します。
文中のサンプルコードは読みやすさのために一部省略しています。

Server Peer

https://github.com/sipsorcery/webrtc-echoes/blob/master/werift/server.ts

import express from "express";
import { RTCPeerConnection } from "werift";
import https from "https";

const app = express();
app.use(express.static("../html")));
app.use(express.json());

app.post("/offer", async (req, res) => {
  const offer = req.body;

  const pc = new RTCPeerConnection({
    iceConfig: { stunServer: ["stun.l.google.com", 19302] },
  });
  pc.onTransceiver.subscribe(async (transceiver) => {
    const [track] = await transceiver.onTrack.asPromise();
    track.onRtp.subscribe((rtp) => {
      transceiver.sendRtp(rtp);
    });
  });

  await pc.setRemoteDescription(offer);
  const answer = await pc.setLocalDescription(await pc.createAnswer());
  res.send(answer);
});

Offer SDP に sendrecv な m-line が含まれる前提のコードです。
送られてきた RTP をそのまま送り返しています。

Client Peer

https://github.com/sipsorcery/webrtc-echoes/blob/master/werift/client.ts

import { RTCPeerConnection, RtpPacket } from "werift";
import axios from "axios";
import { createSocket } from "dgram";
import { exec } from "child_process";

const url = process.argv[2] || "http://localhost:8080/offer";

// input RTP packet
const udp = createSocket("udp4");
udp.bind(5000);
exec(
  "gst-launch-1.0 videotestsrc ! video/x-raw,width=640,height=480,format=I420 ! vp8enc error-resilient=partitions keyframe-max-dist=10 auto-alt-ref=true cpu-used=5 deadline=1 ! rtpvp8pay ! udpsink host=127.0.0.1 port=5000"
);

new Promise<void>(async (r, f) => {
  setTimeout(() => {
    f(); // timeout, failed
  }, 30_000);

  const pc = new RTCPeerConnection({
    iceConfig: { stunServer: ["stun.l.google.com", 19302] },
  });
  const transceiver = pc.addTransceiver("video", "sendrecv");
  transceiver.onTrack.once((track) => {
    track.onRtp.subscribe((rtp) => {
      console.log(rtp.header);
      r(); // done, succeed
    });
  });

  await pc.setLocalDescription(await pc.createOffer());
  const { data } = await axios.post(url, pc.localDescription);
  pc.setRemoteDescription(data);

  await pc.connectionStateChange.watch((state) => state === "connected");
  udp.on("message", (data) => {
    const rtp = RtpPacket.deSerialize(data);
    rtp.header.payloadType = transceiver.codecs[0].payloadType;
    transceiver.sendRtp(rtp);
  });
})
  .then(() => {
    console.log("done");
    process.exit(0);
  })
  .catch((e) => {
    console.log("failed", e);
    process.exit(1);
  });

sendrecv な Transceiver を用意して RTP の送受信をしています。
ServerPeer から RTP が送られてきたら接続に成功したとみなしています。
werift にはメディアの生成、エンコード/デコードなどの機能が備えられていないので、入力するメディアは gStreamer に作ってもらって RTP over UDP で受け取って、それを werift 経由で Server Peer に送るという方法を採用しています。

相互接続試験で発生した問題

werift は Chrome と aiortc としか接続試験を行っていなかったのでその他の WebRTC 実装とはこの相互接続試験で一発でつなげることが出来ませんでした。そこで werift 自体の修正が必要になったのでそのあたりの話について書いていきます。

sipsorcery

sipsorcery との間で起きた問題と解決されるまでの流れはこのissue に残されています。その内容をまとめて紹介します。

DTLS の Elliptic Curve (RFC4492)のネゴシエーション問題

werift は当初、Elliptic Curve のうち x25519 のみをサポートしていましたが sipsorcery は x25519 をサポートしていなかったので DTLS の疎通が出来ないという問題が発生しました。

ただ話がややこしくなるのですが、werift は Elliptic Curve のうち x25519 のみをサポートしているとはいうものの、OpenSSL が Elliptic Curve のうち少なくとも x25519 と P-256 をサポートしなければならない仕様になっているため、werift は P-256 をサポートしていないにも関わらず、取り敢えず OpenSSL 側のバリデーションを突破するために Elliptic Curve のサポートグループに P-256 を含めていました。そのせいで sipsorcery の作者さんがこの問題の原因を探るにあたって少し困惑させる事態になってしまってました....。

何はともあれ原因が分かったので、werift 側で P-256 対応を行ってこの問題は解決しました。

DTLS の extended master secret (RFC7627)

werift は当初、extended master secret のサポートをしていませんでしたが、sipsorcery 側が extended master secret のサポートを強制しているため、問題が起きていました。
werift 側で extended master secret 対応を行ってこの問題は解決しました。

この時点で werift server x sipsorcery client の組み合わせでは相互接続に成功するようになりました!

DTLS の renegotiation indication extension (RFC5746)

依然として werift client x sipsorcery server の組み合わせでは DTLS の接続の段階で失敗していました。sipsorcery の作者に状況を伝えたところ、Pion と sipsorcery の相互接続試験でも同様の問題が発生しており、その原因は sipsorcery が DTLS の実装として採用している Bouncy Castle DTLS が renegotiation indication extension のサポートを強制しているためであるという回答をいただけました。Bouncy Castle DTLS は Jitsi でも採用されているため、renegotiation indication extension を実装することによる相互接続性向上のインパクトは大きいということでしたので、werift でも対応を行うことにしました。
sipsorcery の作者の予想は当たっており、renegotiation indication extension を実装したところ werift client x sipsorcery server の組み合わせでも相互接続に成功しました!!

Pion

werift の host ice candidate

werift には当初、host ice candidate (ローカルの IP アドレス)の取得の実装に問題があり、IPv4 だと0.0.0.0、IPv6 だと::::を host ice candidate としてしまっていました。これが原因で Pion と ICE の疎通に失敗していました。
werift のこの問題を修正したところ Pion との相互接続に成功しました。

まとめ

全体として一番躓いた点は sipsorcery との DTLS の互換性確保でした。DTLS(TLS)には色々な拡張機能が存在し、WebRTC 実装によって必須とする拡張が違います。こういった問題は相互接続試験をしないとなかなか顕在化しないので、相互接続試験に参加して良かったと思います。

sipsorcery の作者さんが GithubActions で相互接続試験の自動化まわりをしてくれてとてもありがたかったです。今後は werift のバージョンを上げる際に、この相互接続試験のリポジトリの werift のバージョンを上げるだけで自動的にリグレッションテスト兼相互接続試験をできるようになりました!

今後は Simulcast や IceRestart など現時点では確認していない項目についてのテストなども行われていくと思うので随時追従していきたいと思います!

Discussion