2012年12月8日土曜日

C# : DNSのポートの扱い概要

DNSについて、漠然と「port53を使うんだなぁ」くらいしか知らなかったのでちょっとだけ調べてみました。 通信プログラム自体あまり触らない方だとはいえ、udpのサワリとか全然知りませんでした。 ちょっと反省。

まずはDNSサーバの種類から。 wikipediaなどの定番の解説を読んだ後、こんなページを発見。

これを全部実感コミで飲み込むのは、運用している人でないと無理ですね。 端末でコードを書く人はとりあえずリゾルバ(スタブリゾルバ)⇔キャッシュサーバ、キャッシュサーバ⇔コンテンツサーバの通信の概要を知っていれば良さそうです。 まぁ、上澄みをサラッと理解したくらいで次へ。

とりあえずリゾルバとキャッシュサーバとの間でどんなポートを使って通信しているのかをネットワーク解析ツールで確認してみました。 こんな感じでした。

リゾルバは受信用のポートとしてエフェメラルポートを空けてからキャッシュサーバの53番ポートにクエリーを送ります。 キャッシュサーバはクエリーの回答をキャッシュから探したりコンテンツサーバに問い合わせたりして調べます。 その回答をリゾルバの受信ポートに向けて送信。 送受信が終わったら受信ポートはCloseです。

エフェメラルポートというのは目的不問で短期間だけ使える使い捨てのポートです。 49152~65535がエフェメラルポートとして使えます。 (↑の画像の50000ってのは適当な値です。) tcpを使ったコードの場合も内部で使われていますが、ラッピングされているのでコードを書く人が意識する必要はありません。 udp通信のコードを書く場合は自分で何番のエフェメラルポートを空けるのかを考える必要があります。

C#でエフェメラルポートを空けるならコードはこんな感じ。

public const int EPHEMERAL_PORT_MIN = 49152;
public const int EPHEMERAL_PORT_MAX = 65535;

private const int _PORT_STEP = 256; // 適当

// 初期値設定、さすがにEPHEMERAL_PORT_MINは使われてそうなので適当な値を足す。
// マルチスレッドで使うならlockが必要。
private int _portPrev = EPHEMERAL_PORT_MIN + _PORT_STEP;

private Random _rand = どっかで初期化;
private bool _isIpV4 = どっかで初期化;

private UdpClient CreateUdpClientWithEphemeralPort()
{
    IPAddress ipa = (_isIpV4 ? IPAddress.Any : IPAddress.IPv6Any);
    int port = _portPrev + 1; // 普段は連番を使う
    UdpClient res = null;

    while (true)
    {
        if (EPHEMERAL_PORT_MAX < port)
        {
            // 定数だと衝突しそうなので乱数を足す
            port = EPHEMERAL_PORT_MIN + _PORT_STEP + _rand.Next(_PORT_STEP);
        }

        try
        {
            IPEndPoint ep = new IPEndPoint(ipa, port);
            res = new UdpClient(ep);
            break;
        }
        catch (SocketException exc)
        {
            if (exc.SocketErrorCode != SocketError.AddressAlreadyInUse)
            {
                throw exc;
            }

            // エフェメラルポートが衝突したら適当な数を足して開き直す
            port += _PORT_STEP + _rand.Next(_PORT_STEP);
        }
    }

    _portPrev = port;
    return res;
}

SocketExceptionでAddressAlreadyInUseが出たら、開けようとしたポートが他で使われているという事です。 ポートの開け直しをします。

とりあえずエフェメラルポートについて書いたけど、「DNS関係で何か作りたいなぁ」と思ってもリゾルバやキャッシュサーバなんて作るもんじゃないですよね。 自分で書くとしたら、リゾルバとキャッシュサーバの間に置くフィルターくらいの物でしょうか? エフェメラルポートを空けるコード要らないです。

フィルターを作るとしたら、大雑把な機能はこんな感じで。

シンプルなコードで書くとこんな雰囲気に。

using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;

namespace SimpleDnsFilterPrototype
{
    // ネットワーク解析ツールなどで通信の様子を観察する
    // ためだけのコードなのでエラー処理省略。
    // デバッガ上で起動して、デバッガで止める。
    class SimpleDnsFilterPrototype
    {
        static void Main(string[] args)
        {
            SimpleDnsFilterPrototype s = new SimpleDnsFilterPrototype();
            s.Run();
        }

        private bool _isIpV4 = true;

        private void Run()
        {
            IPAddress ipa = (_isIpV4 ? IPAddress.Any : IPAddress.IPv6Any);
            IPEndPoint epFilterServer = new IPEndPoint(ipa, 53);
            UdpClient filterServer = null;
            try
            {
                filterServer = new UdpClient(epFilterServer);
                filterServer.Client.ReceiveTimeout = 1000;
                filterServer.Client.SendTimeout = 1000;

                while (true)
                {
                    loopProc(filterServer);
                }
            }
            catch (SocketException exc)
            {
                Debug.WriteLine("例外 : " + exc.Message);
            }
            finally
            {
                if (filterServer != null)
                {
                    filterServer.Close();
                }
            }
        }

        private void loopProc(UdpClient filterServer)
        {
            try
            {
                byte[] dataQuery;
                byte[] dataResponse;

                IPEndPoint epResolver = null;
                dataQuery = filterServer.Receive(ref epResolver);

                Debug.WriteLine("========");
                Debug.WriteLine("Q : " + epResolver.ToString());
                Debug.WriteLine("Q : " + BitConverter.ToString(dataQuery));

                string[] blockQueries = PickBlockDomains(dataQuery);
                if (blockQueries != null)
                {
                    dataResponse = CreateBlockedResponse(blockQueries);
                    filterServer.Send(dataResponse, dataResponse.Length, epResolver);
                    return;
                }

                filterServer.Send(dataQuery, dataQuery.Length, "8.8.8.8", 53); // googleのフリーDNS
                IPEndPoint epCacheServer = null;
                dataResponse = filterServer.Receive(ref epCacheServer);

                Debug.WriteLine("R : " + epCacheServer.ToString());
                Debug.WriteLine("R : " + BitConverter.ToString(dataResponse));

                Filter(dataResponse);
                filterServer.Send(dataResponse, dataResponse.Length, epResolver);
            }
            catch (SocketException exc)
            {
                if (exc.SocketErrorCode != SocketError.TimedOut)
                {
                    throw exc;
                }
            }
        }

        // スタブ。
        // 全Queryがブロック対象のときだけドメインの一覧を返す。
        // ブロック対象が無かったり、一部だけだったときはnullを返す。
        // ポートや識別コードの関係で、ブロック対象だけ個別に応答とかはできないハズ?
        private static string[] PickBlockDomains(byte[] dataQuery)
        {
            return null;
        }

        // スタブ。ブロック対象のipアドレスを未指定アドレスにしたResponseデータを作成。
        private static byte[] CreateBlockedResponse(string[] blockQueries)
        {
            return null;
        }

        // スタブ。
        // dataResponseはキャッシュDNSサーバからの応答。
        // ブロック対象のipアドレスを未指定アドレスで上書きする。
        // ついでにTTLもできるだけ大きく。
        private static void Filter(byte[] dataResponse)
        {
        }
    }
}

スタブの部分は面倒なので作ってません。 雰囲気だけで。

実際にフィルターを作るなら、プロトコルについてある程度調べないとだめですよね。 とりあえず参考になりそうなページはこちら。

説明の丸投げをしたところで、サンプルコード「SimpleDnsFilterPrototype」の動作を見てみましょう。 フィルタリングの機能は作ってませんが、一応 ip:port や通信内容のダンプは見ることができます。 セキュリティについて何も考えていないコードなので、実行時にセキュリティダイアログで聞かれても公開はしないように注意。

windows7で試したのでコマンドプロンプトでnslookupが使えました。 (linuxの人はdigとか使うらしいです。) nslookupでは、「server サーバーのドメイン名」と入れるとnslookup中で使うDNSサーバーを切り替えられます。 DNSサーバーをlocalhostに切り替えて、サンプルコードを動作させた状態で「pieceofnostalgy.blogspot.com」を問い合わせてみました。

C:\~\test>nslookup
既定のサーバー:  ??????????
Address:  ???.???.???.???

> server localhost
既定のサーバー:  localhost
Address:  127.0.0.1

> pieceofnostalgy.blogspot.com
サーバー:  localhost
Address:  127.0.0.1

権限のない回答:
名前:    blogspot.l.google.com
Addresses:  2404:6800:4004:801::100b
          173.194.38.106
          173.194.38.107
          173.194.38.108
Aliases:  pieceofnostalgy.blogspot.com

> exit

サンプルコードの方のログはこうなっています。(タイムアウトの例外表示は省略。)

'System.Net.Sockets.SocketException' のタイムアウト例外たくさん
========
Q : 127.0.0.1:52061
Q : 00-04-01-00-00-01-00-00-00-00-00-00-0F-70-69-65-63-65-6F-66-6E-6F-73-74-61-6C-67-79-08-62-6C-6F-67-73-70-6F-74-03-63-6F-6D-00-00-01-00-01
R : 8.8.8.8:53
R : 00-04-81-80-00-01-00-04-00-00-00-00-0F-70-69-65-63-65-6F-66-6E-6F-73-74-61-6C-67-79-08-62-6C-6F-67-73-70-6F-74-03-63-6F-6D-00-00-01-00-01-C0-0C-00-05-00-01-00-00-0E-0F-00-14-08-62-6C-6F-67-73-70-6F-74-01-6C-06-67-6F-6F-67-6C-65-C0-25-C0-3A-00-01-00-01-00-00-01-2B-00-04-AD-C2-26-6A-C0-3A-00-01-00-01-00-00-01-2B-00-04-AD-C2-26-6B-C0-3A-00-01-00-01-00-00-01-2B-00-04-AD-C2-26-6C
========
Q : 127.0.0.1:52062
Q : 00-05-01-00-00-01-00-00-00-00-00-00-0F-70-69-65-63-65-6F-66-6E-6F-73-74-61-6C-67-79-08-62-6C-6F-67-73-70-6F-74-03-63-6F-6D-00-00-1C-00-01
R : 8.8.8.8:53
R : 00-05-81-80-00-01-00-02-00-00-00-00-0F-70-69-65-63-65-6F-66-6E-6F-73-74-61-6C-67-79-08-62-6C-6F-67-73-70-6F-74-03-63-6F-6D-00-00-1C-00-01-C0-0C-00-05-00-01-00-00-0E-0F-00-14-08-62-6C-6F-67-73-70-6F-74-01-6C-06-67-6F-6F-67-6C-65-C0-25-C0-3A-00-1C-00-01-00-00-01-2B-00-10-24-04-68-00-40-04-08-01-00-00-00-00-00-00-10-0B
'System.Net.Sockets.SocketException' のタイムアウト例外たくさん

52061とか52062とかっていうポートがwindows7のリゾルバが使っているポート番号ですね。 SimpleDnsFilterPrototypeは 127.0.0.1:53 でリゾルバからのクエリーを受け取りgoogleのDNSサーバー(8.8.8.8:53)に丸投げ。 サーバーからのレスポンスを受け取ったらQueryを送信してきた所(127.0.0.1:5206?)に向かって流しています。 詳しいパケットの中身はネットワーク解析ツールで見た方が建設的という、ねぇ...

ちなみに、windowsのdns設定を変えれば、このサンプルコードをブラウザでのネット閲覧など普通の用途で使うこともできます。 普通にDNSサーバーの設定をするダイアログ(??接続のプロパティ/ipv4のプロパティ)でローカルアドレスを指定すればok。 すぐに有効にならなかった場合は「ipconfig /renew」で有効になります。

ニュースサイトをちょっと回っただけでけっこうな量のログが吐かれる事でしょう。 セキュリティとか動作の安定性とかの問題があるので通常使用には耐えられませんけどね。