C#でIPアドレスからPC名を調べる|DNSの逆引き

C#・.NET

操作ログに「どのPCから操作したか」を残したい

業務システムの操作ログには、「誰が・いつ・何をしたか」に加えて、「どのPCから操作したか」を残しておくと、あとで調べるときに役立ちます。

Webアプリでは、接続してきたPCのIPアドレスは分かります。ただ、IPアドレスだけでは、どの席のPCなのかが分かりにくいです。

操作ログの「192.168.0.15」が、誰のPCなのか分からない

そこで、IPアドレスからPCの名前(コンピューター名)を調べて、IPアドレスと一緒に記録します。IPアドレスから名前を調べることを逆引きといいます。ふつうはDNS(名前とIPアドレスを結び付ける仕組み)に問い合わせるので、DNSの逆引きともいいます。

本記事では、C#でIPアドレスからPC名を調べる方法を紹介します。自作の立替精算システムで、操作ログの「端末名」を作ったときに調べたことをもとにしています。

立替精算システムで使ったところ

立替精算システムの操作ログ照会の画面。操作日時・社員・操作・結果・IP・端末名の列がある

操作ログ照会の画面です。IPの列の右に、端末名(PCの名前)を出しています。

私が自作した立替精算システムでは、サーバと同じPCのブラウザから操作したときは、IPアドレスが「::1」や「127.0.0.1」になります。この場合は逆引きをせずに、サーバ自身のコンピューター名を記録しています。社内のほかのPCからの操作は、IPアドレスから名前を逆引きしています。引けなかったときは空にしています。

考え方

  1. 「::1」「127.0.0.1」(同じPC)なら、このPCの名前を返す
  2. 一度引いた結果があれば、それを返す(1時間覚えておく)
  3. 逆引きする。待つのは短い時間(0.3秒)だけ
  4. 引けなかったら空にする(推測で埋めない)
  5. 0.3秒で間に合わなかったときは、裏で続く問い合わせが終わったら、その結果を覚え直す

操作のたびに逆引きの応答を待っていると、画面の動きが遅くなります。PCの名前はめったに変わらないので、「覚えておく」「待つ時間を決める」の2つで、画面を遅くしないようにしています。

C#でIPアドレスからPC名を調べるサンプルコード

動作確認は Windows 11・.NET 10 です(.NET 8 以降で動きます)。Visual Studio でコンソールアプリを作り、Program.cs に貼り付けて実行してください。.NET 10 なら、ファイルに保存して dotnet run ファイル名.cs でも実行できます。

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;

// 調べる IP アドレス
var ipList = new List<IPAddress> { IPAddress.IPv6Loopback, IPAddress.Loopback };

// この PC 自身の IP アドレス(IPv4)を 1 つ加える
ipList.AddRange((await Dns.GetHostAddressesAsync(Dns.GetHostName()))
    .Where(x => x.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(x))
    .Take(1));

// 社内に無い IP アドレス(名前は引けない)
ipList.Add(IPAddress.Parse("192.168.250.250"));

var tanmatsu = new TanmatsuNm();
foreach (var ip in ipList)
{
    var sw = Stopwatch.StartNew();
    var nm = await tanmatsu.GetAsync(ip);
    Console.WriteLine($"{ip,-16} → {nm ?? "(引けない)",-20} {sw.ElapsedMilliseconds,4} ミリ秒");
}

// 2 回目は覚えておいた結果を返すので、すぐに終わる
var sw2 = Stopwatch.StartNew();
await tanmatsu.GetAsync(IPAddress.Parse("192.168.250.250"));
Console.WriteLine($"2 回目(192.168.250.250):{sw2.ElapsedMilliseconds} ミリ秒");


/// <summary>IP アドレスから PC の名前(コンピューター名)を引く。</summary>
public sealed class TanmatsuNm
{
    /// <summary>逆引きを待つ時間。これを過ぎたら名前は空にする。</summary>
    public static readonly TimeSpan Machijikan = TimeSpan.FromMilliseconds(300);

    /// <summary>引いた結果を覚えておく時間。</summary>
    public static readonly TimeSpan Oboeru = TimeSpan.FromHours(1);

    private readonly ConcurrentDictionary<string, (string? Nm, DateTime Kigen)> kioku = new();

    public async Task<string?> GetAsync(IPAddress? ip, CancellationToken cancellationToken = default)
    {
        if (ip is null)
        {
            return null;
        }

        // 同じ PC からの接続(::1・127.0.0.1)は、この PC の名前
        if (IPAddress.IsLoopback(ip))
        {
            return Environment.MachineName;
        }

        // 覚えている結果があれば、それを返す
        var key = ip.ToString();
        if (kioku.TryGetValue(key, out var oboe) && oboe.Kigen > DateTime.UtcNow)
        {
            return oboe.Nm;
        }

        var toiawase = Dns.GetHostEntryAsync(key);
        try
        {
            // 待つのは短い時間だけ。逆引きは CancellationToken では途中で止まらなかったので、
            // WaitAsync で「待つのをやめる」
            var nm = Namae(await toiawase.WaitAsync(Machijikan, cancellationToken));
            Nokosu(key, nm);
            return nm;
        }
        catch (TimeoutException)
        {
            // 待ちすぎた。今回は空にするが、問い合わせは裏で続いているので、
            // 終わったらその結果を覚え直す(次の操作から名前が入る)
            Nokosu(key, null);
            _ = toiawase.ContinueWith(
                t => Nokosu(key, t.Exception is null ? Namae(t.Result) : null),
                TaskScheduler.Default);
            return null;
        }
        catch (OperationCanceledException)
        {
            // 呼び出し元が取り消した(画面を閉じたなど)。引けなかったわけではないので覚えない
            return null;
        }
        catch (Exception)
        {
            // 引けない(SocketException など)
            Nokosu(key, null);
            return null;
        }
    }

    private void Nokosu(string key, string? nm) => kioku[key] = (nm, DateTime.UtcNow + Oboeru);

    // 逆引きできないと、IP アドレスがそのまま返ることがある。名前ではないので空にする
    private static string? Namae(IPHostEntry entry) =>
        string.IsNullOrWhiteSpace(entry.HostName) || IPAddress.TryParse(entry.HostName, out _)
            ? null
            : entry.HostName;
}

実行結果

::1              → SAMPLE-PC               3 ミリ秒
127.0.0.1        → SAMPLE-PC               1 ミリ秒
192.168.0.10     → SAMPLE-PC               4 ミリ秒
192.168.250.250  → (引けない)                311 ミリ秒
2 回目(192.168.250.250):0 ミリ秒

「SAMPLE-PC」「192.168.0.10」は例です。実行したPCの名前とIPアドレスが出ます。ミリ秒の数字も、実行するたびに少し変わります。

3行目は、実行したPC自身のIPアドレスです。自分のアドレスなので、すぐにこのPCの名前が返ります(ネットワークにつながっていないPCでは、3行目は出ません)。

社内に無いIPアドレス(192.168.250.250)は名前が引けず、0.3秒で待つのをやめています。2回目は覚えておいた結果を返すので、待ち時間はありません。

コードの説明

  • IPAddress.IsLoopback で、「::1」「127.0.0.1」かどうかを調べています。同じPCなら Environment.MachineName でコンピューター名を返します。
  • Dns.GetHostEntryAsync(IPアドレスの文字列) で逆引きします。結果の HostName がPCの名前です。「pc01.example.local」のように、後ろにドメイン名などの付いた名前で返ることもあります。
  • このPC(Windows 11・.NET 10)で試すと、名前が引けないときは SocketException(そのようなホストは不明です)になりました。ただ、公式ドキュメントには、IPv6のアドレスでは HostName にIPアドレスの文字列がそのまま入ることがある、と書かれています。名前ではないので、IPAddress.TryParse で読めるものは空にしています。
  • WaitAsync(Machijikan, cancellationToken) は、指定した時間を過ぎると TimeoutException を出して待つのをやめます。問い合わせそのものは止まらずに裏で続くので、ContinueWith で、終わったときに結果を覚え直しています。次に同じPCから操作したときは、名前が入ります。
  • 呼び出し元が取り消したとき(OperationCanceledException)は、名前が引けなかったわけではないので、結果を覚えません。次の操作で、もう一度引きます。
  • Webアプリでは同時に何人も操作するので、覚えておく辞書には、複数の処理から同時に使っても壊れない ConcurrentDictionary を使っています。

CancellationTokenでは止まらなかった

待つ時間を決める書き方として、CancellationTokenSource の CancelAfter で取り消す方法もよく見かけます。

using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromMilliseconds(300));
var entry = await Dns.GetHostEntryAsync(ip.ToString(), cts.Token);

ところが、この書き方で計ってみると、0.3秒で止まらず、逆引きが終わるまで待っていました。このPC(Windows 11・.NET 10)で、0.3秒で待つのをやめる WaitAsync の書き方と比べた結果です。

IPアドレスCancelAfter(0.3秒)WaitAsync(0.3秒)
192.0.2.1(説明用に決められていて、実際には使われないアドレス)約4.6秒待って、引けない約0.3秒で空(裏の問い合わせは約4.6秒で終わる)
192.168.0.250(同じネットワークの、使われていないアドレス)約9.5秒待って、引けない約0.3秒で空(裏の問い合わせは約9.5秒で終わる)
192.168.0.101(DNSに登録の無いNAS)約5秒待って、名前が引ける約0.3秒で空(裏の問い合わせは約5秒で名前が返る)

このPCでは、CancelAfter で取り消しても、IPアドレスからの逆引きは最後まで止まりませんでした。

そこでサンプルコードでは、WaitAsync で「待つのをやめる」形にしています。問い合わせ自体は裏で続きますが、画面の処理は0.3秒で先に進めます。

裏で続く問い合わせは、終わるまでスレッド(処理を動かす係)を1つずつ使います。このPCで、名前の引けないIPアドレス20件を同時に問い合わせると、スレッドが21本に増え、約7秒で全部終わりました。サンプルコードは同じIPアドレスの結果を1時間覚えておくので、同じPCへの問い合わせは、ふつうは1時間に1回で済みます。

ASP.NET Coreで使うとき

// Program.cs:覚えた結果を持ち続けるので、1 つを使い回す
builder.Services.AddSingleton<TanmatsuNm>();

// 画面の処理の中:接続してきた PC の IP アドレスから名前を引く
var ip = HttpContext.Connection.RemoteIpAddress;
var tanmatsuNm = await tanmatsu.GetAsync(ip, HttpContext.RequestAborted);

接続してきたPCのIPアドレスは、HttpContext.Connection.RemoteIpAddress で取れます。TanmatsuNm は引いた結果を覚えておくので、AddSingleton で登録して1つを使い回します。画面の処理では、コンストラクターで TanmatsuNm を受け取って tanmatsu として使います。

HttpContext.RequestAborted を渡しているので、利用者が画面を閉じるなどして要求が取り消されたときは、待つのをやめて空を返します。

注意点

  • 名前が引けるかどうかは、社内のネットワークの作りしだいです。社内のDNSに逆引きの登録(PTRレコード)があれば、すぐに引けます。このPCでは、ルーターのDNSに登録のあるアドレスは0.03秒以内に引けました。
  • DNSに登録が無くても、Windowsが同じネットワークの中で名前を尋ねる仕組み(LLMNR・mDNS・NetBIOSなど)で見つけることがあります。このPCでは、DNSに登録の無いNASの名前が、約5秒かかって返ってきました。0.3秒では間に合わないので、サンプルコードでは1回目の操作は空になり、裏の問い合わせが終わったあとの操作から名前が入ります。同じネットワークのほかの3台の機器は、名前が引けませんでした。
  • VPNやルーターを通った接続では、PCではなくルーターのIPアドレスに見えることがあります。
  • リバースプロキシ(nginxなど)やロードバランサの後ろで動かすと、RemoteIpAddress はプロキシのIPアドレスになります。プロキシが同じサーバの中にあると「127.0.0.1」になり、すべての操作がサーバ自身の名前で記録されてしまいます。元のIPアドレスは X-Forwarded-For ヘッダーで届くので、UseForwardedHeaders で RemoteIpAddress に入れ直します。ヘッダーは偽れるので、信用するプロキシを決めておきます(既定では、同じPCのプロキシだけを信用します)。IISの後ろで動かすとき(アウトオブプロセス)は、この設定が自動で入ります。立替精算システムは、プロキシを通さずに直接つなぐ作りです。
  • DHCP(IPアドレスを自動で割り当てる仕組み)でIPアドレスが変わる社内では、覚えている1時間のうちに同じIPアドレスが別のPCに移ると、前のPCの名前で記録されることがあります。覚えておく時間は、社内のIPアドレスの割り当て方に合わせて決めてください。
  • PCの名前は利用者が変えられるので、本人の確認には使えません。あくまで、あとで調べるときの手がかりとして記録してください。

最後に

以上が、C#でIPアドレスからPC名を調べる方法です。

逆引きは Dns.GetHostEntryAsync の1行でできますが、操作ログのように画面の操作のたびに呼ぶ処理では、「待つ時間を決める」「覚えておく」の2つを入れておくと、画面を遅くせずに使えます。待つ時間は、CancellationToken ではなく WaitAsync で決めるのがポイントです。

この部品を使っている立替精算システムは、こちらで紹介しています。

Pythonでホスト名とIPアドレスを調べる方法は、こちらで紹介しています。

コメント

タイトルとURLをコピーしました