2021年4月24日土曜日

Tap.NET 0.2.0リリース

Tap.NETをリリースしてから3年近く経ちましたが、最近個人的にまたこの仮想NICを使う機会が増えて、しばしば自分で使っています。

そうすると初めていろいろと不満が出てくるわけです。特にクリティカルなのは、世の中はとっくに.NET Frameworkはオワコン化して.NET Coreに移り変わっているというのに、このライブラリは.NET Frameworkのしかもx86ビルドを要求してきます。どう見ても過去の遺物です。

というわけで、少なくとも.NET Core 3.1以上のAnyCPUビルドで使えるようにしたいなーと思い、エッセラホイサと移植作業を行いました。C++/CLIからC#で焼き直しましたので、ほぼ完全に焼き直しています。その際、いくつか破壊的変更もしています。ご了承下さい。

使い方

Nugetからインストールしてください。.NET Core 3.1のパッケージしか入れていませんが、今後作るソフトではこれだけ入っていれば困らないでしょう。

TAP.NET 0.2.0

インスタンス生成方法

旧バージョンではGUIDを与えて初期化するコンストラクターしかありませんでしたが、今バージョンからは無引数のコンストラクラーを用意し、ワンタッチでデバイスを開けるようにしました。今まで入れていなかったのは、単にC++/CLIで実装するのが面倒だっただけですが、C#に移植することでこういうのも簡単にできるようになりました。

using var tap = new Tap();

送信方法

これは以前と何ら変わりません。

using var tap = new Tap();

var ethernetPacket = new EthernetPacket(PhysicalAddress.Parse("90-90-90-90-90-90"), PhysicalAddress.Parse("80-80-80-80-80-80"), PacketDotNet.EthernetType.None) {
    PayloadPacket = new IPv4Packet(IPAddress.Parse("192.168.1.1"), IPAddress.Parse("192.168.1.2")) {
        PayloadPacket = new UdpPacket(123, 456),
    },
};
tap.SendData(ethernetPacket.Bytes);

(これをコンパイルするにはPacket.NETが必要です)

Packet.NETの力でUDPパケットを作りだし、そのバイト列をSendDataメソッドに渡していますそれだけです。もちろんここにはEthernetパケットを渡してあげる必要があります。

受信方法

こちらは前バージョンから少し変えました。

using var tap = new Tap();

tap.DestinationMacFilter = PhysicalAddress.Parse("FF-FF-FF-FF-FF-FF");
tap.EthernetTypeFilter = TapDotNet.EthernetType.IPv4;

tap.DataReceived += (sender, e) => {
    Console.WriteLine(Packet.ParsePacket(LinkLayers.Ethernet, e.Data));
    Console.WriteLine();
};
tap.StartReceiving();

(これをコンパイルするにもPacket.NETが必要です)

データの受信を開始するにはStartReceiving()メソッド、停止するにはStopReceiving()メソッドを呼ぶように変更しています。ですがそんなに違和感のある仕様ではないでしょう。

データを受信したらDataReceivedイベントが発生しますが、イベントが大量に発生するとEventArgsのインスタンス化等でそれなりにコストがかかるため、フィルター機能を用意しています。Ethernetフレームのみを対象のフィルターで、DestinationMacFilter、SourceMacFilter、EthernetTypeFilterに何かしらの値を入れることで、その値を含むパケットを受信したときのみイベントが発生するようになります。これらのフィルターをnullにするとその項目は素通しとなります。EthernetTypeはFlagsの列挙体ですので、複数指定も可能です。細かな仕様が若干違う点もありますが、Ethernetフレームに対してフィルターを掛けられるのは以前のバージョンと同じです。

中身の実装の話

さて、C#/.NET Coreに移植するにあたっていくつか出てきた話を。

P/Invoke

C++/CLIからC#への移植と言っても、基本的にはC++/CLIから直接呼び出していたWind32APIをP/Invokeという機能でC#から呼び出すようにするだけです。TAP-Windowsを利用するためには、CreateFileW関数でデバイスをオープン、DeviceIoControl関数で有効化し、WriteFile関数でデータ送信、ReadFile関数でデータを受信、最後はCloseHandle関数でデバイスをクローズしますので、最低限この5つの関数をC#から使えるようにする必要があります。

internal class Kernel32
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    internal static extern IntPtr CreateFileW(
        [MarshalAs(UnmanagedType.LPWStr)] string filename,
        [MarshalAs(UnmanagedType.U4)] FileAccess access,
        [MarshalAs(UnmanagedType.U4)] FileShare share,
        IntPtr securityAttributes,
        [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
        [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
        IntPtr templateFile);


    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool CloseHandle(IntPtr hObject);

    [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    internal static extern bool DeviceIoControl(
        IntPtr hDevice,
        uint ioControlCode,
        [MarshalAs(UnmanagedType.LPArray)] [In] byte[] inBuffer,
        int ninBufferSize,
        [MarshalAs(UnmanagedType.LPArray)] [Out] byte[] outBuffer,
        int noutBufferSize,
        out uint bytesReturned,
        [In] IntPtr overlapped
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool WriteFile(
        IntPtr hFile, [In] byte[] lpBuffer,
        uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten,
        IntPtr lpOverlapped);

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool ReadFile(
        IntPtr hFile, [Out] byte[] lpBuffer,
        uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead,
        IntPtr lpOverlapped);
}

その他後述しますが、IO中断絡みでの新規対応が必要でしたので、それ周りの関数も同様に準備する必要があります。

CreateFile関数ですが、System.IO.FileAccess列挙体などの値を直接使うようです。最初、Wind32APIで渡すDWORD値をそのまま入れたらうまくデバイスが開けなくてしばらく格闘していました。

// TAPデバイスを開く
hTap = Kernel32.CreateFileW(path, FileAccess.ReadWrite, FileShare.None, IntPtr.Zero, FileMode.Open, FileAttributes.System, IntPtr.Zero);
if(hTap.ToInt64() == -1)
    throw new InvalidOperationException("Cannot open the device");

Thread.Abort()メソッドの廃止

フレームワーク側の変更として、Thread.Abort()メソッドの廃止というものがありました。.NET Frameworkでは問題なく動作していましたが、.NET Coreではサポート外となるようです。

データの受信処理では、受信用のスレッドを生成し、そこでReadFileを呼び出してパケットを受信しています。今までは終了処理時にThread.Abort()メソッドで強制的にスレッドを停止させ終了していたのですが、その方法は使えなくなってしまいました。

その代替方法はCancellationTokenとのことですが、CancellationTokenはあくまでも処理の中断要求を指示して、別スレッドがそのフラグを見たときに中断処理を行うというものです。しかし、受信用スレッドではデータの受信が無ければReadFile関数から処理は帰って来ませんので、いつまで経ってもCancellationTokenのフラグを見ることができず終了できません。

そこで、Win32APIのCancelSynchronousIo関数を使います。これは、特定のスレッドで同期的にIO処理をしている際に、処理を中断させることが出きる関数です。この関数に与えるハンドルはIOデバイスのハンドルではなくスレッドのハンドルになるので注意が必要です。

public void StopReceiving()
{
    if(IsReceiving) {
        canceltokensource!.Cancel();

        // ReadFileの中断
        var thread = Kernel32.OpenThread(0x40000000, false, threadid);
        Kernel32.CancelSynchronousIo(thread);
        Kernel32.CloseHandle(thread);

        watcher!.Join();
        watcher = null;
        canceltokensource = null;
    }
}

void ReceiveThread()
{
    threadid = Kernel32.GetCurrentThreadId();

    var buffer = new byte[1518];

    while(!canceltoken.IsCancellationRequested) {
        if(Kernel32.ReadFile(hTap, buffer, (uint)buffer.Length, out var length, IntPtr.Zero)) {
            if(Filter(buffer, length)) {
                var data = new byte[length];
                Array.Copy(buffer, data, length);
                RaiseDataReceived(data);
            }
        } else {
            var error = System.Runtime.InteropServices.Marshal.GetLastWin32Error();
            if(error != 0 && error != 995) {
                RaiseErrorHappened("ReadFile Error", error);
                break;
            }
        }
    }
}

C#で生成したスレッドではWin32のスレッドIDを知るすべはありませんので、まずはスレッド立ち上げ時にGetCurrentThreadId関数でスレッドIDを保存しておきます。

スレッド終了時は、それをもとにスレッドハンドルを取得し、CancelSynchronousIo関数を呼び出してIOの中止指示を出します。

ReadFile関数は、IOが中止されるとfalseを返し、エラーコード995(ERROR_OPERATION_ABORTED)を残します。これは意図したエラーですので、エラーイベントには流さないように配慮しています。

これにより、ReadFile関数がパケットの受信を待機している最中でも、強制的にそれを中断させてスレッドを終了させることができるようになりました。


てなところで、.NET CoreのAnyCPUからTAP-Windowsを触れるようになりました。

今までずっとprereleaseモードで公開していたからかダウンロード数はあまり伸びていませんが、類似ライブラリは今のところありませんので(多分)、それなりにみんな使ってくれないかなーと期待しています。

えっ?わざわざ仮想NICに外からパケットを流し込みたい人なんてそうそういない?そんなあ。