認証付きRTSPストリームのヘルスチェック

# はじめに

少し前に以下の記事を書いた。

https://qiita.com/SECUAL_masa/items/8cbcddbd0c61894f70fd

この記事のRTSPストリームは認証なしだったが、通常は何かしらの認証付きがほとんどだと思うので、その対応方法を記載する。

以前の記事で書いた「ffmpegの静止画生成可否で判断」だと、

```
ffmpeg -y -rtsp_transport tcp -i "rtsp://ID:PASS@IP/stream" -frames 1 snapshot.png
```

と"ID:PASS@"部分を追加するだけなので実現は可能である。

また場合によっては401を返却する＝疎通はOKと判断できるかもしれないが、前の記事でも触れたRTSPサーバー経由の場合は、RTSPサーバーの認証となり対象カメラの疎通・配信確認はできないかもしれないのと、運用によってはID/PASS変更＝他のサービス利用＝(弊社提供サービスとしては)疎通NGと判断したいため、引き続きこの記事でもDESCRIBEを利用する方法で記載していく。

# libcurl（C言語でのサンプル）

手元にある複数台のIPカメラはDigest認証のようなので、libcurlで実現する方法を調べると以下のオプション設定で可能なような記事を見つけた。

```
    curl_easy_setopt(curl, CURLOPT_HTTPAUTH, CURLAUTH_DIGEST);
    curl_easy_setopt(curl, CURLOPT_USERPWD, "ID:PASS");
```

前回作ったC言語のサンプルに上記の2行を追加して実行すると以下の結果となった。

```
$ ./main
*   Trying nnn.nnn.nnn.nnn:554...
* Connected to HOST (nnn.nnn.nnn.nnn) port 554 (#0)
* Server auth using Digest with user 'USERA'
> DESCRIBE rtsp://HOST:554/12 RTSP/1.0
CSeq: 1
Accept: application/sdp

< RTSP/1.0 401 Unauthorized
< CSeq: 1
< Server: IpCamServer/V1.0
< WWW-Authenticate: Digest realm="IpCamServer/V1.0", nonce="d343xxxxxxxxxxxxxxxxxxxxxxxxaf22"
<
* Connection #0 to host HOST left intact
* Issue another request to this URL: 'rtsp://HOST:554/12'
* Protocol "rtsp" not supported or disabled in libcurl
* Closing connection -1
```

```
* Server auth using Digest with user 'USERA'
```

この1文を見るとDigest認証をしてくれようとしているようには見えるが、

```
* Protocol "rtsp" not supported or disabled in libcurl
```

の1文を見るとなんか怪しい。

引き続き調べると、どうやらlibcurlでは新しめのバージョンでは対応していないようだ。PRを出しても受け付けてくれないとか、古いバージョンではできていたのに、と書いている記事を見かけた。(以前にもバージョンを下げて対処する記事も書いたことがあったが、今回は提供しているサービスで採用したかったため採用は見送り)

# 他の方法で対応(その1)

RTSPクライアント的なOSSがありそうだなーと思って調べてみたら、以下を発見した。

https://github.com/bentiedt/rtspclient

↑のExcamplesにある以下のロジックを見る。

```
client.connect('rtsp://192.168.0.184:554/stream1').then(function(details) {
  client.play();
}).catch(err => {
  console.log(err.stack);
});
```

認証付きで接続(connect)して、成功したら"client.play()"はせずに即終了(close)して認証OK・疎通OKと判断し、失敗したら認証NG or 疎通NGと判断したらいいかなと思い、試行錯誤して出来上がったサンプルが以下。

```
const rtsp = require('./rtspclient/lib/rtsp.js');

var parsedUrl = new URL("rtsp://ID:PASS@CamIP:554/stream");
var url = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname && parsedUrl.pathname.length > 0 ? parsedUrl.pathname : ""}`;

var client = null;
if (!parsedUrl.username || !parsedUrl.password) {
    client = new rtsp.RtspClient();
}
else {
    client = new rtsp.RtspClient(parsedUrl.username, parsedUrl.password);
}

var options = { keepAlive: false, connection: 'tcp' };
client.connect(url, options).then(function(details) {
    // client.play();
    client.close();
}).catch((err, statusCode) => {
    client.close(true);
});
```

以下に補足を。

```
const rtsp = require('./rtspclient/lib/rtsp.js');
```

このrtsp.jsも幾つか修正が必要だった。長くなるのでここではスキップし、「rtsp.jsの修正点について」ｄ後述する。

```
var options = { keepAlive: false, connection: 'tcp' };
```

デフォルトは'udp'だが、client.close()を呼んだタイミングでUDPに関する警告が出ていたのでtcpに変更。

```
client.connect(url, options).then(function(details) {
    // client.play();
    client.close();
}).catch((err, statusCode) => {
    client.close(true);
});
```

前述の通りplay()はせずに即close()。このルートでは疎通OKと判断可能できそう。

rtsp.js内で疎通NGや認証NGの場合はPromise.reject()されているのでcatchした際は、疎通NGと判断できそうである。こちらも即close()。ただここでも警告が出ていたのでtrueを指定して強制的に閉じる。

やりたい事は実現できそうである。

、、、とここまで確認したが、HTTPのGET/POSTでDigest認証する場合もリクエストして401が返ってきたらAuthorizationヘッダを付与して再度リクエストするだけなので、RTSPのDigest認証もGET/POSTがDESCRIBEに変わるだけ＋α（RTSPのお作法的なもの）ぐらいではないか？そっちの方がシンプルに組み込めるのではないか？と思ってさらに以下を試してみた。

# 他の方法で対応(その2)

まずはWebサーバーに80ポートにSocketで接続して、GET/POSTリクエストをwriteで送信し、readで受信する、というサンプルを探す。そのサンプルを参考にGET/POSTをDESCRIBEに書き換えて＋α（RTSPのお作法に合わせて）修正していくと。そして見つかったのが以下。

https://zenn.dev/masakielastic/articles/8f178f227e7eb5

↑の「HTTP/1 サーバーとクライアントにしてみる」を参考にさせていただいた。(感謝！)

できたのが以下。

```
const net = require('node:net');
const crypto = require("crypto");

const UA = 'rtspclient/1.0.0';

var parsedUrl = new URL("rtsp://ID:PASS@CamIP:554/stream");

var url = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname && parsedUrl.pathname.length > 0 ? parsedUrl.pathname : ""}`;

var cseq = 1;
var found401 = false;

const client = net.createConnection({ host: parsedUrl.hostname, port: parsedUrl.port, timeout: 3000 }, () => {
    client.write([
        `DESCRIBE ${url} RTSP/1.0`,
        `CSeq: ${cseq}`,
        `User-Agent: ${UA}`,
        "\r\n"
    ].join('\r\n'));
});

client.on('data', (data) => {
    data.toString().replaceAll("\r\n", "\n").split("\n").map((line, index) => {
        if (index == 0 && line == "RTSP/1.0 200 OK") {
            client.destroy();
        }
        else if (index == 0 && line == "RTSP/1.0 401 Unauthorized") {
            if (cseq >= 2) {
                client.destroy();
            }
            found401 = true;
        }
        else if (found401 && line.indexOf("WWW-Authenticate: ") >= 0) {
            cseq++;
            client.write([
                `DESCRIBE ${url} RTSP/1.0`,
                `CSeq: ${cseq}`,
                `User-Agent: ${UA}`,
                'Accept: application/sdp',
                `Authorization: ${makeAuthorizationHeader(url, cseq, parsedUrl.username, parsedUrl.password, line.substring("WWW-Authenticate: ".length - 1))}`,
                "\r\n"
            ].join('\r\n'));
        }
    });
});

client.on('error', (error) => {
    console.log("error :", error);
});

client.on('timeout', () => {
    console.log("timeout");
    client.destroy();
});

function makeAuthorizationHeader(url, cseq, username, password, wwwAuth) {
  const authDetails = wwwAuth.split(',').map((v) => v.split('='));
  // const nonceCount = ('00000000' + cseq).slice(-8);
  // const cnonce = crypto.randomBytes(24).toString('hex');
  const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)[1].replace(/"/g, '');
  const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)[1].replace(/"/g, '');
  const ha1 = crypto.createHash('md5').update(`${username}:${realm}:${password}`).digest('hex');
  // const path = new URL(url).pathname;
  const path = url;
  const ha2 = crypto.createHash('md5').update(`DESCRIBE:${path}`).digest('hex');
  // const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`).digest('hex');
  const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${ha2}`).digest('hex');
  // const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
  const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",response="${response}"`;
  return authorization;
}
```

それほど説明は必要ないと思われるが、何点かだけ補足を。

```
function makeAuthorizationHeader(url, cseq, username, password, wwwAuth) {
・
・
・
}
```

makeAuthorizationHeader()内のロジックはDigest認証に対応したNodeモジュールのソースから拝借した。
ただそれだけではRTSPのお作法的なところで引っかかるのか、手直しが必要だった。
その点を以下に列挙していく。

```
  // const path = new URL(url).pathname;
  const path = url;
```

URLパスではなく、フルのURLにする必要があった。(IPカメラに依存する可能性がある。countで問題ないIPカメラ/RTSPサーバーもあった)

```
// const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`).digest('hex');
const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${ha2}`).digest('hex');
```

以下に示す修正に合わせてnonceCount、cnonce、authを削除。

```
// const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",response="${response}"`;
```

前の記事でVLCの通信をパケットキャプチャで確認したAuthorizationヘッダにはqop, algorithm, ncがなかったため、それに合わせて修正。("nc=xxx"や"cnonce=xxx"があるとダメだった。IPカメラに依存する可能性はある)

これら手直しで、手元にある複数台のIPカメラ/RTSPサーバーで認証が通るようになった。

# rtsp.jsの修正点について

* とあるIPカメラが返却するレスポンスのデータが想定外(RFC違反？)なものがあり、その影響でエラー判定されていた点を修正
* 不正リクエストでもステータスコードを正しく上位に返却できるように


## とあるIPカメラが返却するレスポンスのデータが想定外(RFC違反？)なものがあり、その影響で無限ループに陥っていた点を修正

レスポンスデータの中に改行コード(\r\n)が1セット多く含まれていたため、以下のelseに入りエラーとなっていた。IPカメラ/RTSPサーバーとの認証はOKとなってもこの影響でエラー判定(疎通NG・認証NG)されることになるので修正が必要である。

```diff_javascript
-            else {
-                // unexpected data
-                throw new Error("Bug in RTSP data framing, please file an issue with the author with stacktrace.");
-                }
-            }
+            else {
+                // unexpected data
+                if (this.readState == ReadStates.SEARCHING && (index + 2 >= data.length && data[index] == 0x0d || data[index] == 0x0a)) {
+                    console.log(`SKIP`);
+                    index++;
+                }
+                else {
+                    throw new Error("Bug in RTSP data framing, please file an issue with the author with stacktrace.");
+                }
+            }
```

## 不正リクエストでもステータスコードを正しく上位に返却できるように(その1)

DESCRIBEが400でエラーになるケースで想定外のCseqになっていると判断されていたので、前述のサンプルソース側でcatchし400を判断できるように修正した。

```diff_javascript
-            const responseHandler = (responseName, resHeaders, mediaHeaders) => {
-                if (resHeaders.CSeq !== id && resHeaders.Cseq !== id) {
-                    return;
-                }
-                this.removeListener("response", responseHandler);
-                const statusCode = parseInt(responseName.split(" ")[1]);
+            const responseHandler = (responseName, resHeaders, mediaHeaders) => {
+                const statusCode = (responseName && (responseName.split(" ")).length >= 2) ? parseInt(responseName.split(" ")[1]) : -1;
+                console.log(`statusCode = ${statusCode}`);
+                if (resHeaders.CSeq !== id && resHeaders.Cseq !== id) {
+                    reject(new Error(`Unexpected CSeq. (now:${resHeaders.CSeq} , expected:${id}]`), statusCode > 0 ? statusCode : 400);
+                    return;
+                }
+                this.removeListener("response", responseHandler);
+                // const statusCode = parseInt(responseName.split(" ")[1]);
```

## 不正リクエストでもステータスコードを正しく上位に返却できるように(その2)

前述のサンプルソースでステータスコードを判断できるように修正した。

```diff_javascript
-                    reject(new Error(`Bad RTSP status code ${statusCode}!`));
+                    reject(new Error(`Bad RTSP status code ${statusCode}!`), statusCode);
```