Pure Python Tracepath

2020年07月14日 火曜日


【この記事を書いた人】
島 慶一

最近はセキュリティログ解析などに取り組んでいます。何か面白そうな話題があればお声がけください。IIJ/IIJ-II技術研究所所属。

「Pure Python Tracepath」のイメージ

こんにちは。島です。技術研究所でセキュリティログ解析などに取り組んでいます。

先日、入用でtracepathに相当する機能をPythonで作る場面が出てきました。tracepathは自分のノードから対象ノードまでのインターネット上の経路(ルータ)を発見し、遅延やMTUを計測するプログラムで、iputilsの一部として配布されています。

tracepathとは

tracepathと似たプログラムにtracerouteがあり、ほぼ同等の機能を提供しています。わたしを含む年齢層高めの人には、tracerouteの方が馴染みのあるプログラムかもしれません。tracerouteはその実行に管理者権限が必要ですが、tracepathは一般ユーザでも実行できるという違いがあります。これはtracerouteが管理者にしか操作できないraw socketを使っているのに対して、tracepathは一般ユーザでも利用できる拡張socket APIを利用して通信状態に関係する付加的情報を参照しているからです。

tracepathは対象ノードに至る各ルータまでの遅延などを計測します。これをどうやって実現しているのか、ご存知の方も多いかもしれません。利用しているのはICMP(Internet Control Message Protocol)あるいはIPv6ネットワークならばICMPv6(ICMP for IPv6)で通知される情報です。

いろいろバッサリ端折って動作を解説すると、tracepathはパケットの寿命であるTTL(Time to Live)、IPv6であればHop Limitを1に設定し、対象ノードに対してUDPパケットを送信します。TTL / Hop Limitはルータを通過する際に1減らされ、0になるとパケットが破棄されます。ですから、TTL / Hop Limitが1に設定された場合は最初のルータでUDPパケットが捨てられます。この時、ルータはパケットの寿命が切れたことを知らせるため、UDPパケットの送信元にICMP / ICMPv6パケットを返送します。これを受け取り、UDPパケットの送信時刻とICMP / ICMPv6パケットの到着時刻の差を計算することで遅延(正確には往復遅延)を求めます。次に、TTL / Hop Limitを1増やして、同じ手順を実行します。今度は二つ目のルータまで到達し、やはりICMP / ICMPv6メッセージが返送されます。これを対象ノードに到達するまで繰り返します。

単純ですね。

でも実際に作って試してみた人はそう多くないのではないかと思います。既にtracepathというすばらしいプログラムがありますし、普通はわざわざ作ることはないでしょう。

tracepathでできなかったこと

今回、Pythonのプログラムから特定の対象ノードまでの遅延を知る必要がありました。単純に遅延を測るということであれば、Pythonのsubprocessモジュールでpingtracepathを呼び出しても良いのですが、以下の二つの理由でPython自身で書き起こすこととしました。

  1. 対象ノードまでICMP / ICMPv6の疎通があるかどうかわからない状況でした。その場合は辿り着くことができる最遠のノードまでの遅延を知りたい。よってpingではなくtracepathのような途中のルータの情報を調べる機能が必要でした。
  2. tracepathは計測する中継ルータの最大数を指定できます。もし途中のルータから応答がない場合は、TTLを増やしながら最大数(特に指定しなければ30)までリトライしますが、計測の途中で到達性がなくなると、最大TTLに到達するまでリトライが発生します。tracepathはUDPパケットの応答がくるまで1秒待ちますから、全く応答がなければ最大30秒待たなくてはなりません。だからと言って、中継ルータ最大数を最初から小さな値にしてしまうと、対象ノードまでたどり着かない恐れがあります。よって、最大連続リトライ数のような形で、一定回数疎通が確認できない場合に計測を切り上げる必要がありました。

tracepathオープンソースとして公開されています。すでに存在するコードを元に必要な機能を書き足しても良いのですが、これはC言語で書かれています。今回のプロジェクトは最終的にDockerでパッケージすることにしていたので、C言語でプログラムを書くとコンテナ作成時(ビルド時)にコンパイルする必要があります。元プロジェクトがPythonなので、Pythonで書けばそのまま組み込めて便利です。

すでに世界のどこかのPythonistがtracepathを作っているかも。当然考えました。すべてを調べることは不可能ですが、さっと調べるといくつかの実装を発見できます。最近のものではicmplibというモジュールがありました。試してみたのですが、上記の2の条件を満たす機能は提供されていないので、いずれにせよ作り込みが必要になること、tracepath以外の機能(pingなど)も提供しているため、管理者権限が必要なICMP / ICMPv6 socketを利用していたこと、そして最後にIPv6に関して正しく中継ルータを検出していないことなどがあったため、自作することにしました。icmplibモジュール自体は複数ノードへの同時pingなど便利な機能も提供しているようなので、このまま開発が続けばいいなと思います。

あと、単に作ってみたかったという気持ちがあったことも事実です。

いざ書き始め

tracepathの仕組みを実現するためには、UDPパケットの送信と、ancillary dataの読み出しができなければなりません。前者は当然サポートされています。後者はrecvmsg()システムコールで実現できるのですが、Python2のsocketモジュールにはrecvmsg()呼び出しのためのメソッドが実装されていないようでした。よってPython3のみの対応ということになりますが、最近はPython3も広く使われているようなので良しとします。

Ancillary dataとは、socket通信において、通信データに加えてやり取りされる付随的な情報です。今回のようなICMP / ICMPv6を利用して通知された状況は、関連するsocketにancillary dataとして渡され、必要に応じてsocket利用者が取り出すことができるようになっています。tracerouteではICMP / ICMPv6パケットを管理者権限で作成したraw socketで受信し、関係する情報を拾い出す必要がありましたが、ancillary dataの仕組みを使えば通信に利用しているsocket経由で対応情報が得られます。管理者権限も不要です。付与する権限は少なければ少ないほどいいですからね。

足りないもの

Ancillary dataを受け取るためには、事前にどの情報を受け取りたいかをシステムに登録しておく必要があります。これにはsetsockopt()システムコールが使われます。今回の場合、ICMP / ICMPv6で通知されたエラー情報を取り出すため、通信に利用するsocketにIP_RECVERRもしくはIPV6_RECVERRフラグをセットします。C言語では以下のようなコードで実現されます。

int on = 1;
setsockopt(sockv4, IPPROTO_IP, IP_RECVERR, &on, sizeof(on));
int on = 1;
setsockopt(sockv6, IPPROTO_IPV6, IPV6_RECVERR, &on, sizeof(on));

ところが、PythonのsocketモジュールにはIP_RECVERRIPV6_RECVERRの定義がありません。Linuxでは<netinet/in.h>でそれぞれ1125と定義されていますが、この値はOSに依存している可能性があるので、できればモジュール側で定義してOS間の違いを吸収して欲しいところです。とはいうものの、OSによってIP_RECVERRなどのエラー受信の対応状況にばらつきがあると思われるので、意図的に外してあるのかもしれません。今回作成するPythonプログラムはDocker内で実行され、そのホストOSはLinuxと決まっているため、Linux前提でハードコードします。

UDPパケットを送信して、それが途中のルータで破棄されるとICMP / ICMPv6パケットが返送されます。recvmsg()でその情報を取り出せるのですが、ancillary data経由で取り出すことができる情報はICMP / ICMPv6の情報に限らず、実に様々です。よって、IP_RECVERRを指定したとき返される情報と、それ以外の情報では、返される情報の構造が全く異なります。どの情報の取り出しがサポートされているかはOS依存ということになります。

IP_RECVERRおよびIPV6_RECVERRでは、socket拡張エラー(struct sock_extended_err)と命名された構造体が使われます。

struct sock_extended_err {
    u_int32_t   ee_errno;   /* error number */
    u_int8_t    ee_origin;  /* where the error originated */
    u_int8_t    ee_type;    /* type */
    u_int8_t    ee_code;    /* code */
    u_int8_t    ee_pad;
    u_int32_t   ee_info;    /* additional information */
    u_int32_t   ee_data;    /* other data */
    /* More data may follow */
};

この構造体をデコードする機能もsocketモジュールでは定義されていないため、Pythonのstructモジュールを利用してバイト列を対応するフィールド値に変換します。

ee_errnoフィールドにはシステムコールのerrnoに対応する値が入ります。今回の場合だと、途中のルータでパケットの寿命が切れた場合はEHOSTUNREACH、対象ノードまで到達したけれど、UDPパケットを受け取るプロセスが存在しない場合はECONNREFUSEDが返ります。対象ノードのパケットフィルタの設定によってはEACCESが返ることもあります。Linuxでは<errno.h>で定数定義されており、Pythonではerrnoモジュールで参照できます。

ついでに言うと、たまたま対象ノードでUDPパケットを受信してしまうプロセスが存在する場合も(稀だと思いますが)あります。この場合はICMP / ICMPv6は返ってこず、tracepathとは全く関係のない何らかのUDPパケットが返ってくるか、あるいは単に何も起きない可能性があります。実際のコードではこの辺りの対応も必要です。

ee_originはエラーの出所を示します。ICMPであれば2、ICMPv6であれば3です。Linuxでは<linux/errqueue.h>で定義されていますが、Pythonのモジュールでは該当する値が定義されていないため、こちらもハードコードします。

ICMP / ICMPv6のタイプとコードの値は、上記構造体のee_typeおよびee_codeフィールドに格納されています。これらの値もPythonのモジュールには定義が見当たりませんでした。流石にICMP / ICMPv6のタイプ、コードくらいは定義しておいて欲しいところです。

最後に、ICMP / ICMPv6を返送してきたノードのアドレスが、socket拡張エラー構造体の直後にstruct sockaddr_inあるいはstruct sockaddr_in6の形で格納されます。ここまで見てようやく、自分が送ったUDPパケットがどこまで届いたのかが判定できます。

エラー処理が正常処理

tracepathはエラー処理の塊です。エラー処理こそが正常処理、もはやtracepathにエラー処理はないのかもしれません。言い過ぎですが。

tracepathの仕組みは簡単ですが、普段は気にかけない複数のパターンのネットワークエラーを想定する必要があるので、想像しているよりも面倒です。ですが、意図的にエラーになるように送信したパケットに対して、実際にルータからICMP / ICMPv6が返送されてきて、期待した通りのエラーコードが入っていると嬉しくなってしまいます。普段ならパケットを送信した後は返り値が0以上(正常終了)かどうかくらいしか見ないところですが、tracepathのようなネットワーク診断プログラムの場合はマイナス値が返ってきてからが本番です。この辺りも面白いですよね。

いろいろと調べながら完成したPythonのみで記述されたtracepathですが、特に隠す理由もないので恥ずかしながらGithubで公開しています。連続で応答がない場合に計測を切り上げるという、とてもニッチな機能がついています(笑)。なお、必要な情報が遅延情報だったので、オリジナルのtracepathが提供しているMTU計測機能は(まだ)実装していません。

間違い、改善点などあれば可能な範囲で対応しますので、GithubのIssueで挙げてもらえれば幸いです。

島 慶一

2020年07月14日 火曜日

最近はセキュリティログ解析などに取り組んでいます。何か面白そうな話題があればお声がけください。IIJ/IIJ-II技術研究所所属。

Related
関連記事