Help us understand the problem. What is going on with this article?

できるだけ簡潔にSIP Serverを作る(説明途中)

More than 3 years have passed since last update.

以前サーバ側&制御信号側のVoIP技術者だったために新しい言語を学ぶときはまずSIP Serverを作ることにしています。

この記事では最低限これくらいのことを実装すれば、相手がちゃんとRFC3261に沿っていればSIP Serverとして動作させることができるよ、という話を書きます。

言語はpythonを使います。10年くらい前に書いたコードなので2.7です。あと凄腕プログラマが書いたらもっと格好良くできると思いますが、まぁこれでも動くよね、くらいで読んでください。

SIP信号の受信

RFC3261に従うとTCPにも対応しなくてはならないのですが、UDPにも対応しなくてはならないことになっているため、相手がUDPで喋ってくれることに期待してUDPだけに対応することにします。UDPだと基本的に信号の切れ目を気にしなくて良い(TCPだとだらだらとデータが流れてくるのでどこで信号が区切られるのかをきちんと見ないといけない)ので、プログラムを簡潔にするには有効です。

大概の言語でも同じだと思いますが、UDP用のソケットを作って、そのソケットをIP及びポートとバインドして、あとはそのソケットでrecvfromし続けます。
コードにするとこんなイメージ。

class Proxy:

  def __init__(self, ip, port):
    self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    self.sock.bind((ip, port))

  def run(self):
    while True:
      buf, addr = self.sock.recvfrom(0xffff)
      # これでbufに受信した信号、addrに送ってきた相手のアドレスが入っている

カンタンです。このソケットはあとあと違う場所で信号送信にも使うので
なお受信バッファサイズに0xffffとしているのはUDPの仕様上このサイズまでしか受信できないからです。(ホントはUDPパケットのヘッダ部分も含めたサイズなので受信できるデータサイズはもうちょっと小さいけれど、ぱっと見0xffffの方が見やすいので気にしない)

SIP信号の解析(とりあえずざっくり)

これ以降の処理をしやすいように、受信した信号をざっくり解析します。私的にはここに秘伝の技を詰め込んでいます。まぁそれなりに調べさえすれば誰でも思いつくことなのかもしれませんが。

なお、相手が正しいSIP信号を送ってくることを前提にしてるので、狙って送ればバグを引き起こすこともできます。

全体像はこう。

class Message:

  def __init__(self, buf):
    buf = re.sub(r'^((\r\n)|(\r)|(\n))*', "", buf)
    m = re.search(r'((\r\n\r\n)|(\r\r)|(\n\n))', buf)
    self.body = buf[m.end():]
    buf = re.sub(r'\n[ \t]+',' ', re.sub(r'\r\n?', "\n", buf[:m.start()]))
    ary = buf.split("\n")
    m = re.match(r'(([A-Z]+) ([^ ]+) )?SIP\/2\.0( (\d+) ([^\n]+))?', ary[0])
    self.method, self.requri, self.stcode, self.reason = \
      m.group(2), m.group(3), m.group(5), m.group(6)
    self.hdrs = []
    for buf in ary[1:]:
      name, buf = re.split(r'\s*:\s*', buf, 1)
      self.hdrs.append(Header(name, re.split(r'\s*,\s*', buf)))

各部分を説明します。

RFC3261を眺めると、SIP信号の冒頭にはCRLFが複数乗っかっていても良いことになっています。CRLFというのは、¥rか、¥nか、¥r¥nのいずれかです。これは意味のある情報ではないのでとりあえず消します。¥r¥nか、¥rか、¥nはpythonの正規表現でこう書けます。

r'(\r\n)|(\r)|(\n)'

なお、色々はっきり分かるように私は必要以上に括弧を書くほうです。

これが0個以上ある、だと

r'((\r\n)|(\r)|(\n))*'

とかけますね。さらに先頭に、という条件を加えると

r'^((\r\n)|(\r)|(\n))*'

になります。この正規表現を使ってbufの置換を行うと、

buf = re.sub(r'^((\r\n)|(\r)|(\n))*', '', buf)

となります。

次に、ヘッダとボディの間の区切りとなる、empty-lineを見つけて、そこで2分割します。

empty-lineは連続するふたつのCRLFとして表現され、正規表現で表すと

r'((\r\n\r\n)|(\r\r)|(\n\n))'

となり、この文字列をSIP信号の中から探し出してそれより後ろをボディとして切り出すと

m = re.search(r'((\r\n\r\n)|(\r\r)|(\n\n))', buf)
self.body = buf[m.end():]

となりました。

さて、ここまでCRLFを¥r¥nか¥rか¥nとして見てきましたが、面倒でしょうがないので、ここで¥nに置き換えちゃいます。

じゃぁもっと早く置き換えればよかったんじゃないの?という疑問も生じるかと思いますが、body部分に¥r¥nであることに意味がある情報が含まれているかもしれないし、もしきちんとContent-Lengthヘッダを使ってデータ長を見たくなったとしたら、ボディが書き換わってしまうと困るので、ボディ以外の部分だけを対象に置き換えします。

¥nに置き換えるので、¥r¥nか¥rが対象になります。¥rと、それに続く¥nがあってもなくても良い、ということになるので、これを正規表現で表すと

r'¥r¥n?'

ですね。

この直前に受信したデータのどこまでがヘッダなのかを見つけてあるので、それを踏まえて、

re.sub(r'\r\n?', '\n', buf[:m.start()])

で置換できます。

さて、SIPヘッダには面倒なことに途中でCRLFを含めることができる仕様になっています。途中に入っているCRLFに意味はなく、単に見栄えだけのための仕様です。こんなの誰が嬉しいんだろう。これも処理する時に邪魔でしょうがないので、どうにか消しておきたいです。幸い、ヘッダの途中にCRLFがあった場合はそのあとにWSP(空白文字, ひとつ以上の半角スペースかタブ)を入れる決まりになっています。先ほどの処理でCRLFは¥nに置き換わっているので、ひとつ以上のスペースかタブが後に続く¥nは、そのスペースかタブも含め単独の半角スペースに置き換えちゃいます。

正規表現は

r'¥n[ ¥t]+'

ですね。

先ほど書いたCRLFを¥n化する処理もまとめて一行で書いちゃうこととして、

buf = re.sub(r'\n[ \t]+',' ', re.sub(r'\r\n?', '\n', buf[:m.start()]))

で綺麗になります。
これでもうヘッダもその前のStart-Lineも¥nで区切られた1行ずつに収まっていることになるので、分割して配列にします。

ary = buf.split("\n")

このary内の各要素を処理していけばよくなりました。ゼロ番目の要素がStart-Line、1番目以降の要素がヘッダです。

続けてStart-Lineを解析します。
Start-Lineには2種類あって、Request-LineとStatus-Lineですね。

Request-Lineはmethod、Request-URI、SIP-Versionのみっつが半角スペース一つずつを挟んで並ぶ形になっていて、methodは大文字半角英字が複数並び、Request-URIは色々な文字が許容されていますが、少なくとも半角スペースは許容されていません。

r'([A-Z]+) ([^ ]+) SIP\/2\.0'

Status-LineはSIP-Version、Status-Code、Reason-Phraseのみっつが同じく半角スペース一つずつを挟んで並ぶ形になっています。

r'SIP\/2\.0 (\d+) ([^\n]+)'

最後の改行以外としているところは、すでに改行ごとに分割することで改行は消えているので、任意の文字列として解析しても何ら問題ないです。改行がないことを前提とできない場所にコピペとかされちゃった時に間違えないようにという程度です。

Request-Lineは最後がSIP-Version、Status-LineははじめがSIP-Versionになっているので、簡単に一気に解析できます。

r'(([A-Z]+) ([^ ]+) )?SIP\/2\.0( (\d+) ([^\n]+))?'

これを使って解析し、その結果を保持すると

m = re.match(r'(([A-Z]+) ([^ ]+) )?SIP\/2\.0( (\d+) ([^\n]+))?', ary[0])
self.method, self.requri, self.stcode, self.reason = m.group(2), m.group(3), m.group(5), m.group(6)

続けてヘッダを解析します。aryという配列の1行目からがヘッダの生データが入っているので、

for buf in ary[1:]:
  pass # ここでbufを解析する

みたいな感じで解析します。
ヘッダは、ヘッダ名があって、HCOLONがあって、そのあとにヘッダの値が続きます。HCOLONというのは、コロン(:)の前後に空白があってもなくても良いってやつです。ということで、まずコロンの前後に空白がある文字列で分割します。

for buf in ary[1:]:
  name, buf = re.split(r'\s*:\s*', buf, 1)

ヘッダ値はカンマで要素が区切られるのでそこで分割しておきます。

re.split(r'\s*,\s*', buf)

ただし、本当はこれはあんまりイケてなくて、ヘッダ値の中にダブルクオーテーションで括られた文字列の中にカンマが含まれているものがあるとバグります。もうちょっと難しい正規表現を書けばどうにかできます。

解析した結果を格納しておくこんなクラスを作っておいて、

class Header:
  def __init__(self, name, vals):
    self.name, self.vals = name, vals

これにヘッダ解析結果を突っ込むことにして、

self.hdrs = []
for buf in ary[1:]:
  name, buf = re.split(r'\s*:\s*', buf, 1)
  self.hdrs.append(Header(name, re.split(r'\s*,\s*', buf)))

ちゅーことで、全部くっつけると

class Message:

  def __init__(self, buf):
    buf = re.sub(r'^((\r\n)|(\r)|(\n))*', "", buf)
    m = re.search(r'((\r\n\r\n)|(\r\r)|(\n\n))', buf)
    self.body = buf[m.end():]
    buf = re.sub(r'\n[ \t]+',' ', re.sub(r'\r\n?', "\n", buf[:m.start()]))
    ary = buf.split("\n")
    m = re.match(r'(([A-Z]+) ([^ ]+) )?SIP\/2\.0( (\d+) ([^\n]+))?', ary[0])
    self.method, self.requri, self.stcode, self.reason = \
      m.group(2), m.group(3), m.group(5), m.group(6)
    self.hdrs = []
    for buf in ary[1:]:
      name, buf = re.split(r'\s*:\s*', buf, 1)
      self.hdrs.append(Header(name, re.split(r'\s*,\s*', buf)))

となりました。

ひと段落

疲れたので続きはまた今度。疲れ果ててすでに適当な説明になってきたのでそれもいずれ更新します。完成品は以下です。

https://github.com/zurustar/xylitol/blob/master/xylitol.py

いいねの数次第で他の記事の更新との優先度を調整します

zurustar
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away