LoginSignup
2
3

More than 1 year has passed since last update.

ATerm WH832AルータをFreePBX(Asterisk)のトランク先として利用するために、drachtioでB2BUAサーバを作成する

Last updated at Posted at 2022-09-18

概要

ある中国地方に展開している電力系光プロバイダが「光電話」というIP電話サービスを提供しています。このプロバイダからレンタルされるルータ Aterm WH832A を使って、FreePBX(Asterisk)からの発着信を可能にします。

このルータにはFXSポートが2つ付いており、通常はここにアナログ電話機を接続してIP電話を利用します。しかし、このルータのスマートフォン向けのIP電話機能を使うと、SIP端末やAsteriskのトランクから直接IP電話を利用できます。


引用元:NEC ATerm WH832Aのマニュアルより

ただしAsteriskのトランク先として使う場合、発信は問題ないのですが、そのままでは着信ができません。

tel:スキーマをサポートしていないPJSIPドライバ

ATermルータから送られるTo:ヘッダに tel: URI が使用されるため、これをサポートしていないAsteriskのPJSIPドライバは 416 Unsupported URI Scheme エラーを返してしまいます。

具体的な着信時のSIPメッセージは次の通りです。ATermルータからの着信時、INVITEメッセージのTo:ヘッダにtel: URIが指定されています。

<===
INVITE sip:s@192.168.0.12:5060 SIP/2.0
f: "0123456789" <sip:0123456789@1.2.3.4>;tag=160879891
t: <tel:023456789;phone-context=1.2.3.4>

===>
SIP/2.0 416 Unsupported URI Scheme

この問題はすでにフォーラム等で報告されています:

PJSIPドライバが利用するPJSIPライブラリ自体は tel: URI をサポートしていますが、Asteriskの側がサポートしていません。この点、Asteriskのコードの改造を試してみましたが、416エラーを出す部分をバイパスしてtel: URIを処理させようとすると、受信SIPメッセージを表す構造体におかしなポインタが格納されSEGVしてしまいます。

現状のAsteriskの多くのコードがsip: URIを前提としているようで、tel: URIをサポートするには、各所のコードにかなり手を入れてテストをしないといけないのかもしれません。この点が、未だに開発側がtel: URIをサポートするつもりがない(orしようとしてもできない)理由と思われます。

tel: を sip: へ変換するB2BUAサーバを間に立てる

Asterisk側をどうにもできないのであれば、ATermルータとAsteriskの間にB2BUA(Back-to-Back User Agent)サーバを立て、これにtel: URIをsip: URIに変換させるという手が考えられます。

今回は、こうしたメッセージの書き換えを伴うB2BUAサーバをJavaScriptで容易に記述できる drachtio を用いることにしました。

ノード構成

ノード構成は上の形を想定します。drachtioを利用するには、まずB2BUAサーバとなる drachtio-server を立ち上げます。これをNode.jsで作られた制御アプリケーション(上の図ではdrachtio-controller)から制御します。

Asteriskのトランク先には drachtio-server を指定します。Asteriskが drachtio-server にREGISTERし、 drachtio-server がAtermルータにREGISTERする形となります。

処理する必要があるSIPメッセージ

制御アプリケーションを通じて drachtio-server に処理させる必要のあるSIPメッセージは上の通りです。他のメッセージは drachtio-server がよしなにしてくれます。

各々のIP電話システムの要件に応じて、他のメッセージも制御する必要が出てくるかと思いますが、私の利用ケースはこれで十分でした。

RTPストリームには関与しません。各ピアから来たSDPをそのまま転送します。

手順

drachtio-server を構成する

まずB2BUAサーバとなる drachtio-server を構成します。DockerホストとなるノードがFreePBXノードとは別に一つ必要です。

Dockerイメージが提供されているので、これを利用します。

ネットワーク構成を単純にするために、Dockerのipvlanネットワークを使い、コンテナをLAN内に直接公開します。

※標準のbridgeネットワークを使い、5060/udp(SIP)と9022/tcp(drachtioの制御ポート)を公開する形でも、多分問題なく動作すると思います。

# ipvlanネットワーク "my_lan" を作成する
#
# XX/YY にはコンテナにIPアドレスを自動割り当てする場合、その範囲を指定する
# IFNAME にはコンテナのIPアドレスに割り当てられるホスト上のインターフェイス(e.g. eth0)を指定する
docker network create -d ipvlan --subnet=192.168.0.0/24 --ip-range=192.168.0.XX/YY \
  --gateway=192.168.0.1 -o ipvlan_mode=l2 -o parent=IFNAME my_lan

# コンテナを起動(先に後述の手順で設定ファイルを用意すること)
docker run -v /data-drac-config:/config -it --name drac --rm \
  --net my_lan --ip 192.168.0.23 drachtio/drachtio-server:latest \
  drachtio -f /config/drachtio.conf.xml

上のコマンドで起動した場合、設定はDockerホスト上に /data-drac-config/drachtio.conf.xml を作成して行います。開発リポジトリのひな型を書き換えます。

次の通り、最低限の設定は<admin>タグだけで大丈夫なはずです。

/data-drac-config/drachtio.conf.xml
<drachtio>
    <!-- 注: 0.0.0.0 の前後に空白・改行が入ると Invalid Argument エラーになるので注意 -->
    <admin port="9022" secret="MyPassword">0.0.0.0</admin>
    ...
</drachtio>

drachtio-controller (制御用のNode.jsアプリケーション) を作成する

続いて drachtio-server を制御するNode.jsアプリケーションを作成します。

プロジェクトの作成

mkdir drachtio-controller
cd drachtio-controller
npm i drachtio-srf@4 drachtio-mw-registration-parser@0 config@3 pino@8 rxjs@7 ts-node@10 @types/node
  • drachtio-srf が drachtio を制御するクライアントライブラリです
  • drachtio-mw-registration-parser は REGISTER リクエストの情報を解析するミドルウェアです
    • 本質的に必須なのは上2つだけです
  • configpino公式のyeomanテンプレートでも用いられている、設定読み込みとログ出力のライブラリです
  • rxjs は定期的なREGISTERを行うために使用していますが、 setInterval を使うなら不要です
  • ts-node, @types/node はTypeScriptを使わず素のJavaScriptで書くなら必要ありません

設定ファイルの準備

config npm で読み込ませる設定ファイル config/local.json を作成します。

config/local.json
{
  // drachtio サーバへの接続情報
  "drachtio": {
    "host": "192.168.0.23",
    "port": 9022,
    "secret": "MyPassword"
  },
  // FreePBX(Asterisk)のホスト
  "pbxHost": "192.168.0.12",
  // ATermルータへの接続情報
  "gateway": {
    "host": "192.168.0.1",
    // ATermの電話設定における、発着信したい電話番号に対応した内線番号
    "user": "3",
    // 認証ユーザ名とパスワード
    "authUsername": "ATermの電話設定で指定したユーザ名",
    "authPassword": "ATermの電話設定で指定したパスワード",
    // REGISTERを行う間隔
    "registerIntervalSec": 30
  },
  "localEndpoint": {
    // drachtio Proxy 自身の内線番号。何でも構わない。
    // 言い換えるとsip:XXX@192.168.0.23のXXXの部分になる番号。
    "user": "1"
  },
  // pino npm に渡されるログ設定
  "logging": {
    "level": "debug"
  }
}

本体(main.ts)の実装

アプリケーション本体である main.ts を実装します:

main.ts
import Srf from 'drachtio-srf'
import regParser from 'drachtio-mw-registration-parser'
import config from 'config'
import pino from 'pino'
import {concat, interval, of, Subscription} from 'rxjs'

const srf = new Srf()
const logger = pino(config.get('logging'))

// config/local.json の gateway 部分のラッパ
class GatewayConfig {
    host: string
    user: string
    authUsername: string
    authPassword: string
    registerIntervalSec: number

    get sipAddress() {
        return `sip:${this.user}@${this.host}`
    }
}

// config/local.json の drachtio 部分
type DrachtioConfig = { host: string, port: number, secret: string }

// SIPリクエストをログに出力するユーティリティ
function logRequest(req: any): void {
    logger.info(`received ${req.method} from ${req.protocol}/${req.source_address}:${req.source_port}`)
}

// tel: URIを解析するユーティリティ
function parseTelUri(uri: string) {
    const match = uri.match(/tel:(\d+);phone-context=([0-9\.]+)/)
    return {
        number: match[1],
        phoneContext: match[2]
    }
}

// sip: URIを解析するユーティリティ
//
// ※drachtio-srfにSrf.parseUri という関数があるが、 <> 付きを処理してくれない
function parseSipUri(uri: string) {
    const match = uri.match(/sip:(\d+)@([0-9\.]+)/)
    return {
        user: match[1],
        host: match[2]
    }
}

// config/local.json の読み込み
const drachtioConfig = config.get('drachtio') as DrachtioConfig;
const gatewayConfig = Object.assign(new GatewayConfig(), config.get('gateway'))
const localEndpointAddress = `sip:s@${drachtioConfig.host}`
const pbxHost = config.get('pbxHost') as string

// ATermルータへのREGISTERを行う
//
// See Also: https://github.com/drachtio/drachtio-srf/blob/main/test/uac.js
function registerToGateway() {
    logger.info("REGISTER to the gateway...")
    srf.request(`sip:${gatewayConfig.host}`, {
        method: 'REGISTER',
        headers: {
            To: gatewayConfig.sipAddress,
            From: gatewayConfig.sipAddress,
            Contact: localEndpointAddress
        },
        auth: {
            username: gatewayConfig.authUsername,
            password: gatewayConfig.authPassword
        }
    }).then((req) => {
        req.on('response', (res) => {
            if (res.status === 200) {
                logger.info("successfully registered")
            } else {
                logger.error(`rejected after auth with ${JSON.stringify(res)}`)
            }
        });
    }).catch((err) => {
        logger.error(`failed to retrieve a response: ${JSON.stringify(err)}`)
    })
}

let registerSubscription: Subscription

// drachtio-server へ接続する
srf.connect(drachtioConfig);
// 接続処理完了時の処理
srf.on('connect', (err, hp) => {
    if (err) throw err;
    logger.info(`connected to drachtio listening on ${hp}`)

    // 接続を完了したら、ATermルータへの定期的なREGSITERを開始する。
    // 最初にすぐREGISTERし、その後registerIntervalSec秒ごとに繰り返す。
    registerSubscription = concat(of(0), interval(gatewayConfig.registerIntervalSec * 1000))
        .subscribe((num_) => {
        registerToGateway()
    })
})

// drachtio-server 制御中に生じたエラー発生時の処理
srf.on('error', (err) => {
    logger.error(err)
    // 定期REGISTER処理を停止
    registerSubscription?.unsubscribe()
})

// REGISTER リクエストの情報を解析するミドルウェアを登録
srf.use('register', regParser);

// AsteriskからのREGISTERの処理
const onRegisterFromPbx = (req, res) => {

    // 無条件で成功させる
    res.send(200, {
        headers: {
            'Contact': req.registration.contact[0].uri
        }
    })
};

// OPTIONSの処理
const onOptions = (req, res) => {
    // ただの導通確認なので200 OKを返す
    res.send(200)
};

// INVITE(発信と着信)の処理
const onInvite = async (req, res) => {
    const srf = req.srf
    const sourceAddress = req.source_address as string

    // どちらかが通話を切った時に、もう一方を終わらせるようにする処理。
    const destroyOtherOnDialogEnd = ({uac, uas}) => {
        uas.other = uac;
        uac.other = uas;
        [uas, uac].forEach((dlg) => dlg.on('destroy', () => {
            logger.info('call ended');
            dlg.other.destroy();
        }));
    }

    try {
        // 発信と着信を区別する
        if (sourceAddress == gatewayConfig.host) {
            // ATermルータからの着信の場合
            logger.info(`handling the invite from the gateway (sourceAddress=${sourceAddress})`)

            // To: ヘッダを解析
            const uriOfToHeader = parseTelUri(req.get('to'))

            // AsteriskへINVITEを発行し、B2BUAセッションを開始する
            const {uas, uac} = await srf.createB2BUA(req, res, `sip:${uriOfToHeader.number}@${pbxHost}`, {
                // このときTo:ヘッダをsip: URIに書き換える
                headers: {
                    "To": `<sip:${uriOfToHeader.number}@${uriOfToHeader.phoneContext}>`
                }
            });

            logger.info('call connected successfully');

            destroyOtherOnDialogEnd({uac, uas})
        } else if (sourceAddress == pbxHost) {
            // Asterisk からの発信の場合
            logger.info(`handling the invite from the PBX (sourceAddress=${sourceAddress})`)

            const uriOfToHeader = parseSipUri(req.get('to'))

            // ATermルータへINVITEを発行し、B2BUAセッションを開始する。
            const {uas, uac} = await srf.createB2BUA(
                req, res, `sip:${uriOfToHeader.user}@${gatewayConfig.host}`,
                {
                    // INVITE時にも認証が必要
                    auth: {
                        username: gatewayConfig.authUsername,
                        password: gatewayConfig.authPassword
                    },
                    headers: {}
                })

            destroyOtherOnDialogEnd({uac, uas})
        } else {
            // 他のホストからINVITEは来ないはず
            logger.error(`unknown INVITE issuer: ${sourceAddress}`)
            res.send(501, `INVITE from host other than Gateway/PBX are not implemented.`)
        }
    } catch (err) {
        const reportUsualError = (msg) => {
            logger.info(`[from ${err.res.source_address}] ${msg}`)
        }
        // 通常生じる非成功ステータスを処理
        if (err.status == 487) {
            // 呼び出しの中断
            reportUsualError(`487 Request Terminated (the caller hooked on)`)
        } else if (err.status == 503) {
            // 着信拒否
            reportUsualError(`503 Service Unavailable (the callee rejected the call)`)
        } else {
            logger.error(err, 'Error in connecting call')

            // まだピアにレスポンスを返していなければ、500を返す
            if (!res.finalResponseSent) {
                res.send(500, `Internal Server Error (${err.name}@phula-drachtio-pbx-aterm-proxy)`)
            }
        }
    }
};

// 以上の処理をミドルウェアとして登録する
srf.invite(onInvite)
srf.register(onRegisterFromPbx)
srf.options(onOptions)

// すべてのリクエストをログ出力
srf.use((req, res, next) => {
    logRequest(req)
    next()
})

起動

次のようにして制御アプリケーションを起動します。

./node_modules/.bin/ts-node main.ts

FreePBX側でのPJSIPトランク作成

FreePBXでPJSIPトランクを作成します。

  • Username: 先の localEndpoint.user 設定で指定したもの
  • Secret: 何でもいい
  • SIP Server: 192.168.0.23
  • 以下はうまくいかない場合にチェックしてみてください(必須なのかどうかを検証していません)
    • Authentication: Outbound
    • Registration: Send
    • DTMF Mode: Inband
    • User = Phone: No
    • Direct Media: No
    • RTP Symmetric: Yes
    • Force rport: Yes

これで発着信が可能になるはずです。

おわりに

業務用の電話回線として一か月以上運用してみましたが、特にトラブルなく安定して発着信ができています。

drachtio は今回のようなケースには大変ありがたいフレームワークでした。ほとんどの定型的なSIPメッセージの処理を担ってくれますので、書き換えたい部分だけを実装するだけでB2BUAサーバを作成することができます。今回のtel: URI対策に限らず、色々と応用が利くと思います。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3