SOCK_RAWソケットの意外な振る舞い

組み込み系やネットワークアプライアンスを創っている人は、linuxでPF_PACKETプロトコルファミリのSOCK_RAWタイプのソケットを創ったことがあると思う。私ももちろんそういうことをしてた。今回はそこで出会った不思議なことをネタに。

典型的な使い方

教科書的には、

いまどきのソケットプログラミング

いまどきのソケットプログラミング

という素晴らしいものがあるのでそちらで。
テンプレート的にはifnameを読み書きしたいイーサネットバイス名として、

#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netpacket/packet.h>
#include <net/ethernet.h>
#include <net/if.h>
#include <sys/ioctl.h>
....
	int s;
	struct sockaddr_ll sa;
	struct ifreq ifr;
	int ret;
	
	memset(&ifr, 0, sizeof(ifr));
	if (strlen(ifname) >= sizeof(ifr.ifr_name)) {
		return -1;
	}
	strcpy(ifr.ifr_name, ifname);

	s = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
	if (s < 0) {
		perror("socket");
		return -1;
	}
	if ((ret = ioctl(s, SIOCGIFINDEX, &ifr)) < 0) {
		perror("siocgifindex");
		close(s);
		return -1;
	}
	memset(&sa, 0, sizeof(sa));
	sa.sll_family = AF_PACKET;
	sa.sll_ifindex = ifr.ifr_ifindex;
	if ((ret = bind(s, (const struct sockaddr *)&sa, sizeof(sa))) < 0) {
		perror("bind");
		close(s);
		return -1;
	}

といったコードを記述することで、以下read(2)/write(2)で読み書き出来る。そう、難しくも何ともない。root特権か、CAP_NET_RAW権限があれば実行出来、レイヤ2フレームをそのまま読み書き出来るのでそれこそどんなことでも出来る。実に強力だ。
しかし、不思議なことが時々起こることに気がついた。
他のネットワークインタフェースのパケット(正確にはフレームだが)が読める時がある

お漏らしなんてそんな子に育ては覚えはありません

実際は、滅多に起きないので気にしないという考え方もあるだろう。だが、気に入らない。そんな子(ソフトウェア)に育てた覚えは無い。ということで調査開始した。もちろんグーグル先生に聴いても分からない(まぁだから書くことにしたのだが)。
ポイントは、起きるときには、せいぜい一パケット起きるかどうか、ということと、起動直後におきているということ。
そう、socket()実行後から、bind()を実行する間に他のインタフェースから受信したパケットをbind()後のread()で読み込んでいたということ。そんなバカなと思ったが、man 7 packetよく読んでみると。

       By default all packets of the specified  protocol  type  are  passed  to  a
       packet  socket.  To  only get packets from a specific interface use bind(2)
       specifying an address in a struct sockaddr_ll to bind the packet socket  to
       an  interface. Only the sll_protocol and the sll_ifindex address fields are
       used for purposes of binding.

これを超訳すると、

デフォルトでは(指定されたプロトコルタイプの)全てのパケットがPACKETソケットにパスされる。指定したインタフェースからのパケットだけを読み込みたい場合は、bind(2)を使え。struct sockaddr_llにバインドしたいインタフェースを指定しろ。sll_protocolとsll_ifindexフィールドだけがバインドでは使われる。

つまり、socket(2)してからbind(2)するまでは全インタフェースからのパケットを受信するということ。そりゃ、人間時間ではほぼ無限小だが、これまで、そういう計算機時間の隙間に何度も悩まされてきたので素直に納得。起きる可能性がある時はそれが0でない限り起きる。

回避方法

ここまで分かれば後は簡単。タイムアウト値を0にしてrecv()を繰り返すことでバッファクリアをすれば良い。ポイントは、

  • 無限ループにならないように、カーネルの受信バッファ長を取得しておく。
  • この受信バッファは2倍の値が帰ってくるので2で割っておく。
  • socket(2)してからbind(2)するまでに受信したパケットの総量は高々このバッファ長しか無い。
  • この制限を加えておかないと、高トラフィックの場合、クリアがいつまでたっても終わらなくなる可能性がある。
  • recv(2)でMSG_DONTWAITフラグを付けて呼ぶとfcntl(2)でF_SETFLでO_NONBLOCKしたのと同じ効果が出てくる。
  • ノンブロック状態でデータが無いときには、recv(2)は-1を返し、errnoにEAGAINかEWOULDBLOCKをセットする。

以上を考慮して

#define MAXFRAMELEN 65536
int sock_rewind(int s)
{
        int i;
        int maxbuf = 0;
        int maxbuflen = sizeof(maxbuf);
        char buf[MAXFRAMELEN];
        int buflen = sizeof(buf);

        if (getsockopt(s, SOL_SOCKET, SO_RCVBUF, &maxbuf, &maxbuflen)) {
                perror("getsockopt");
                return -1;
        }
        maxbuf /= 2;
        printf("RCVBUF %d\n", maxbuf);
        do {
                i = recv(s, buf, buflen, MSG_DONTWAIT); 
                if (i < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
                        printf("%s\n", strerror(errno));
                        break;
                }
                maxbuf -= i;
        } while (maxbuf > 0);
        printf("RCVBUF %d\n", maxbuf);
        return 0;
}

デバッグ用のどのくらいクリアしたかを表示する文や、バッファが空になった時のエラーコードを表示する文が追加されているが、まぁこんなものでしょう。このsock_rewind()をbind(2)した後に呼ぶことで、それまでの受信バッファをきれいにしてくれるということ。

こういう状況をデバッグするときには、着目しているポイント(今回ならsocket(2)からbind(2)の間)にsleep(3)を入れて、時間を引き延ばすことで、滅多に起きないことをいつも引き起こすようにして確認する。