前回 mikrotik ルータに無事ひかり電話を収容1したので平穏な日々を過ごしていましたが、今までアナログ回線で使用していた留守電が無い事を不便に思い、asterisk にも voicemail 機能がある様ですが歳を食ったせいか仕事なら兎も角興味が無いとマニュアルを読むのが辛い。
というわけで最近始めた rust で voicemail クライアントを適当に作ってみます。
前提
- 電話回線は1回線(ナンバーディスプレイが無い環境はどうなるか分かりません)
- asterisk(v20.5.2、前回の記事の routeros 上のコンテナで稼働)
- rust の開発環境(開発時点では v1.89.0)
- routeros(開発時点で 7.20rc2)を扱える程度のネットワークの知識
- 開発には docker を使ってますが podman でもなんでも良いです
構成
構想としては sip クライアント着信のみ、PCM の g.711 をそのまま DB に格納し、web サーバで確認というシンプルな構成です。当然ですが機器を減らすために routeros にしたので、routeros 上のコンテナで常時稼働させます。
シーケンス図はダブルアローが無いため冗長になりすぎて途中で面倒臭くなったので雰囲気で。
Asterisk
前回使用した extensions.conf の [default] を少し書き換え、全ての子機が15秒間応答しない場合に子番101の voicemail に着信させる設定を入れます。なお voicemail とやり取りする音声データは μ-law(8000Hz、8bit)固定とします。opus コーデックに変更しないと彼女の息遣いが聞こえないという方は勝手に変更してください。ただしクライアント側も opus に対応する必要があります。
[101]
type=endpoint
transport=hikari-tp
context=ext
disallow=all
allow=ulaw ; μ-law 形式固定、前回の設定のそのまま
exten => _X.,1,Dial(${PHONEALL},15,r) ; PHONEALL に登録した子機を15秒鳴らす
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) ; 応答がない場合
same => n(unavail),Dial(PJSIP/101) ; 101(voicemail)にダイアル
same => n,Hangup()
same => n(busy),Dial(PJSIP/101)
same => n,Hangup()
Rust
rust 界隈は sip のライブラリがちらほら出てきたところで、ちょうど絶賛開発中のrsipstack2 の examples に良いサンプルがついていたのでこれを流用します。sip は枯れた技術なのでクレート自体開発中でも基本的な部分は、況してや趣味で使う分には十分でしょう。
データを溜める DB ですが PCM データを UDP で受信しチャンク毎に BLOB に書き込むだけなので、回線が一つだけということもあり SQLite を使用します。もちろん多数のスレッドをぶん回し、回線を同時に受け付けたいぜ、みたいな方は DB 選定の前に迷わず PBX を購入してください。昔と違って IP-PBX は SaaS からアプライアンスまで格安です。
ところで SQLite は単なるファイルでトランザクションは作成出来ますが thread 上で扱うことが出来ず、ループの中で非同期で書き込むと rusqlite に Send が無い(thread safe じゃない)と怒られます。これは SQLite 側の制約なので rust で開発した事を喜ばしく思う反面、泣く泣くトランザクションで作成するのを諦め、毎回コミットする形で逐次 BLOB へ追加で書き込む方式とします。
因みにトランザクションでの書き込みは DB オープンクローズが無いため、300 KB のデータを 160 byte のチャンクでループしても 40 ms 程度で SQLite へ書き込めます(当然環境依存です)が、今回は逐次 UDP で受けたデータをリアルで書き込むだけなのでオーバーヘッドは無視して分割されたチャンク毎にコミットします。
データ容量について、例えば 1秒程度で相手が通話を切断した場合 asterisk から来る音声データは μ-law 形式なので、8000 x 8 bit (x 1 モノラル) で 64000 bit = 1秒 8 KB。コンテナホストが disk フルで困らないように 30秒で強制的に切断すると、30 x 8 KB で 240 KB ですが、BLOB の追加書き込みを行うには領域を先に確保しなければなりません。まあ 100 件溜めたところで 25 MB 程度なのでこれで良いでしょう。3気になる様であれば空の voicemail.db を外部のストレージに置き、コンテナ側でマウントさせる手もあります。もし BLOB の容量を切り詰めたい人は逐次 DB 書き込みをせずに通話切断後にまとめて BLOB に書き込むと必要サイズ分だけ書き込むことが出来ますが、コンテナのヒープ上にバッファとして溜める必要があるためあまりお勧めはしません。とは言っても 240 KB です。好きにしてください。
余談ですが SQLite は敬虔なガチクリスチャンが作成しており4彼らの行動規範が code of conduct(現在は code of ethics)に含まれています。私の様な八百万神仏混合土着信仰は兎も角、本来異教徒は使用を控えられた方が良いのかも知れません。ただ当時殆どの携帯や OS、ソフトウェアに組み込まれましたので今更な情報かもですが。およそ25 年前に SQLite を知り全世界の組み込み SQLite が猛烈に普及した時期でしたので、聖戦が流行り出したこともあり興味深く世の中の動向を見ていたのを覚えています。現在は自前で作成しているのか分かりませんが、まあ人間なんてのは都合が良ければコンプライアンスから教義規範信条まで二の次なんですかね。世界は都合の良い答えしか望んで無いということの一端を垣間見た感じがしました。まあ若干大袈裟ではありますが。
さて SQLite に溜め込んだ音声データを聞くために折角ですから actix-web で web インターフェースを作成します。このあたりはテンプレ通り作れば誰でも作成出来ますので説明は割愛。ページの構成や javascript で突っ込まれても適当に作ってるだけなので答えられません。チャッピー5に人類の不甲斐なさでも聞いてみてください。
ただ μ-law 形式のまま再生するのに、g.711 コーデックのライブラリ6を中国の方が作っていたので流用しています。ありがたい。彼らは生成 AI 界隈でも追う側から直ぐにコスト面などで有利に立ちそうな勢いですよね。なんでも cuda をバイナリレベルで最適化し再構築したことで輸出規制の掛かった最新 HW を使わずに一世代前の nvidia の GPU で AI 界隈に殴り込み掛けたんですからすごいもんです。今のところ日本は半導体の前工程、後工程で有利に立ってますがそのうち HW も駆逐しそうな勢いが彼らにはあります。不動産バブルのツケの後処理も AI に聞いているところかもしれません。
なお、音声応答は適当な音声合成のサイトで作成し μ-law 形式で保存しています。AI と接続して AI に応答させ自動文字起こしなどが一瞬頭をよぎったのですが、たかだか月に1、2 件程度しか掛かってこない家電なので、気が向いたら作ってみてください。
RouterOS
話がところどころ脱線しましたが、そもそもレール自体が私の人生には見当たらなかったので今更気にせず先に進みます。
今回は veth 同士の container 間通信を行うため veth peer で接続する場合、arp リクエストのブロードキャストを受信する必要があります。GUI から veth を設定する場合、bridge に登録した veth は全てのブロードキャスト、マルチキャスト、ユニキャストのフラッドを受け付ける状態になっていますが、輻輳を避けるため私なんかはコンテナの flood 関係は当たり前ですが外部とはなんの問題も無く通信出来るので軒並み off ってます。ただ今回はコンテナ間通信を行うため broadcast flood をオンにすることで veth peer の通信が出来ます。正確に言うと同じ bridge 上にある veth 同士の通信は broadcast flood をオンにしないと、少なくとも udp は通信出来ません。当初 pjsip 側の sip-nat に問題があるんだと見ていたので丸 2日くらいコンテナ間の UDP 通信が出来なくて貴重な毛が抜けました。まあ分かっちゃえばなんてこたない話なんですが、浅学なのでフラッドのオンオフが関連するとは思ってもなかったのが正直なとこです。これって仕様なのかな。
コンテナ
あとは routeos でも動くコンテナを作成してデプロイすれば終わりです。当初 gcr.io/distroless コンテナで作成していましたが、routeros がコンテナの nonroot ユーザに対応していない様で、SQLite のファイルをマウントした書き込みが出来ませんでした。一度デプロイしてしまえば routeros を再起動してもコンテナの状態を保持したままなのでそれでも良いのですが、電話番号に名前を付けられる様に改修したことで DB の永続性が必須となってしまい、結局普通に外部マウント可能な alpine で作成し直しています。もちろんコンテナを使用せずにローカルで稼働させても問題はありません。
私見ですが昨今セキュリティなんてのは最早存在しないも同じで、ツールと穴さえあれば彼らは入ってきちゃうので、例えば携帯を wifi 経由でルータ側の DNS を使用させたりしたら DNS キャッシュ弄られて、同じルータを使用しているデスクトップは気付かずに接続しちゃうとか、インターネットに接続していなくても bluetooth 経由で弄られ、オンラインになった途端にバックドア開けられるとか嫌な世の中ですよ。まあこのあたりは全て妄想なので忘れてください。忘却は先に進むために人間に与えられた権利です。まあ相手は覚えてますけどね。何はともあれお客さんに言い訳する場合にはセキュリティの対策は必須です。
あ、色々忘れすぎて忘れてましたが asterisk のコンテナは routeros の v7.19 あたりから /etc/hosts ファイルがコンテナ作成時に書き換えられてしまう様で、前回の記事で asterisk のコンテナに hosts ファイルをマウントしていましたが問答無用で消される仕様に変わりました。
なのでコンテナの entrypoint で /etc/hosts に ntt の sip 接続先を追加することで sip 接続先が名前解決される様になります。まあコンフィグに ip ベタ打ちでも問題無いとは思いますが試していません。
何度も言いますが、西用のサンプルです。
ENTRYPOINT ["/bin/sh","-c","echo '124.245.0.1 ntt-west.ne.jp'>>/etc/hosts && /docker-entrypoint.sh"]
というわけでニッチな構成でニッチな趣味なのでここまで読んだ稀有な方が居るのか分かりませんが、以下が下らない蘊蓄と知識を詰め込んだソースです。
voicemail
お疲れ様でしたノシ
-
変なツッコミ入れられてもアレなので、今回のスキーマだと大体 1行あたり B ツリーヘッダや PK 諸々含め 307 KB、100 件で約 30 MB。vacuum は手動でも使用しないので留守電を聞いたら消す運用は必須ですが、フラグメント考慮しても最大 3 倍程度見積もれば十分かと。連絡先テーブルは1行あたりヘッダ、PK 込みで UTF8 の日本語が入ったとしても概算 320 bytes 程度、100 件登録しても 31 KB なので音声側のテーブルに比べたら微々たるものです。ただ一度拡張しちゃうと vacuum じゃないと shrink しないので注意が必要です。 ↩