tech.guitarrapc.cóm

Technical updates

C#で隣接するIPv4 CIDRを集約表記にする

隣接するIPv4のCIDRを集約表記にする処理を書いたのでメモです。

IPv4 CIDRをまとめるとは

CIDRはIPアドレスの範囲を表すための表記方法です。例えば192.168.0.0/24192.168.1.0/24は重複しないアドレス空間を示す隣接するCIDRです。隣接するCIDRはネットワークアドレスが適切な境界に揃っている場合1はまとめることができ、この例なら192.168.0.0/23と集約できます。

これの何がうれしいかというと、ルーティングテーブルやセキュリティグループのエントリ数を減らすことができます。例えばAzureのNetwork Security Groupは4000エントリーを持つことができます。セキュリティグループはネットワークインタフェースに1つしかつけられないので、事実上上限4000個で何とかする必要があります。もし4000エントリー以上許可するならCIDRをまとめることで、動作に影響なくエントリー数を減らすことができます。同様にAWSのセキュリティグループも「エントリー数の上限は60」「ENIあたりのセキュリティグループのアタッチ上限は5つ」とあまり膨大な数は設定するものじゃありません。

C#で隣接するIPv4 CIDRを集約表記にする

目下でやりたいのはIPv4 CIDRなのでIPv4に限定します。2

APIは以下のように集約したいCIDRを渡すと、集約されたCIDRを返すものです。

var aggregated = CidrMergerv4.CollapseAddresses(["192.168.0.0/24", "192.168.1.0/24"]);
Console.WriteLine(string.Join(",", aggregated));
// 192.168.0.0/23

C#はCIDR表記を直接扱えないので、内部でIPRangeという構造体を使ってIPアドレスの範囲を表現します。IPRangeStartEndでネットワークアドレス範囲を表現します。

public static class CidrMergerv4
{
    /// <summary>
    /// Aggregates a list of CIDR blocks into the smallest possible list of CIDR blocks. Return snapshot of the aggregated list.
    /// </summary>
    /// <param name="cidrs">IPv4 CIDRs. e.g., 192.168.0.0/24</param>
    /// <returns></returns>
    public static IReadOnlyList<string> CollapseAddresses(IEnumerable<string> cidrs)
    {
        // Convert each CIDR to an IPRange and sort by Start address.
        var ranges = cidrs
            .Select(cidr => ParseCIDR(cidr))
            .OrderBy(r => r.Start)
            .ToList();

        if (ranges.Count == 0)
            return [];

        // Merge overlapping or contiguous ranges.
        var mergedRanges = new List<IPRange>(ranges.Count);
        var current = ranges[0];
        for (int i = 1; i < ranges.Count; i++)
        {
            if (current.End + 1 >= ranges[i].Start)
            {
                // Merge if adjacent or overlapping.
                current.End = Math.Max(current.End, ranges[i].End);
            }
            else
            {
                mergedRanges.Add(current);
                current = ranges[i];
            }
        }
        mergedRanges.Add(current);

        // Convert each merged range back into a minimal list of CIDR blocks.
        var resultCIDRs = new List<string>(mergedRanges.Count);
        foreach (var range in mergedRanges)
        {
            resultCIDRs.AddRange(RangeToCIDRs(range.Start, range.End));
        }

        return resultCIDRs;
    }

    /// <summary>
    /// Parses a CIDR string (e.g., "192.168.0.0/24") into an IPRange.
    /// </summary>
    /// <param name="cidr"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentException"></exception>
    private static IPRange ParseCIDR(string cidr)
    {
        var parts = cidr.Split('/');
        if (parts.Length != 2)
            throw new ArgumentException("Invalid CIDR format: " + cidr);

        var ip = IPAddress.Parse(parts[0]);
        var prefix = int.Parse(parts[1]);

        uint ipUint = IPToUint(ip);
        uint mask = prefix == 0 ? 0 : 0xFFFFFFFF << (32 - prefix);
        uint network = ipUint & mask;
        uint broadcast = network | ~mask;

        return new IPRange { Start = network, End = broadcast };
    }

    /// <summary>
    /// Converts an IPAddress to a 32-bit unsigned integer using Span.
    /// </summary>
    /// <param name="ip"></param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException"></exception>
    private static uint IPToUint(IPAddress ip)
    {
        Span<byte> bytes = stackalloc byte[4];
        if (!ip.TryWriteBytes(bytes, out _))
            throw new InvalidOperationException("Failed to write IP address bytes.");
        // Ensure big-endian for network order
        if (BitConverter.IsLittleEndian)
            bytes.Reverse();
        return MemoryMarshal.Read<uint>(bytes);
    }

    /// <summary>
    /// Converts a 32-bit unsigned integer to an IPAddress using Span.
    /// </summary>
    /// <param name="ipUint"></param>
    /// <returns></returns>
    private static IPAddress UintToIP(uint ipUint)
    {
        Span<byte> bytes = stackalloc byte[4];
        MemoryMarshal.Write(bytes, in ipUint);
        if (BitConverter.IsLittleEndian)
            bytes.Reverse();
        return new IPAddress(bytes);
    }

    /// <summary>
    /// Converts a given IP range (from start to end) into a minimal list of CIDR blocks.
    /// </summary>
    /// <param name="start"></param>
    /// <param name="end"></param>
    /// <returns></returns>
    private static List<string> RangeToCIDRs(uint start, uint end)
    {
        var result = new List<string>();
        while (start <= end)
        {
            // Determine the maximum prefix allowed by the current start address alignment.
            byte alignmentPrefix = 32;
            while (alignmentPrefix > 0)
            {
                uint mask = 0xFFFFFFFF << (32 - alignmentPrefix);
                if ((start & mask) != start)
                    break;
                alignmentPrefix--;
            }
            alignmentPrefix++;

            // Calculate the maximum prefix based on the remaining address count.
            uint remaining = end - start + 1;
            byte maxPrefixBasedOnRange = (byte)(32 - Math.Floor(Math.Log(remaining, 2)));

            // Choose the more restrictive prefix.
            byte prefix = Math.Max(alignmentPrefix, maxPrefixBasedOnRange);

            // Generate the CIDR block.
            var cidr = $"{UintToIP(start)}/{prefix}";
            result.Add(cidr);

            // Move to the next block's start address.
            uint blockSize = (uint)Math.Pow(2, 32 - prefix);
            start += blockSize;
        }
        return result;
    }

    // Represents an IPv4 address range.
    private struct IPRange
    {
        public uint Start;
        public uint End;
    }
}

アドレスがまとまる様子

いくつか試すと、以下のように集約されます。いい感じですね。

// 集約前
["192.168.0.0/24", "192.168.3.0/24", "192.168.4.0/24", "192.168.5.0/24"]
// 集約後
["192.168.0.0/24", "192.168.3.0/24", "192.168.4.0/23"]

長い例です。

// 集約前
{
  "192.168.0.0/27",
  "192.168.0.32/27",
  "192.168.0.64/27",
  "192.168.0.96/27",
  "192.168.0.128/27",
  "192.168.0.160/27",
  "192.168.0.192/27",
  "192.168.0.224/27",
  "192.168.1.0/27",
  "192.168.1.32/27",
  "192.168.1.96/27",
  "192.168.2.0/28",
  "192.168.2.16/28",
  "192.168.2.32/28",
  "192.168.2.48/28",
  "192.168.2.64/28",
  "192.168.3.0/26",
  "192.168.3.64/26",
  "192.168.3.128/26",
  "192.168.3.192/26",
  "10.0.0.0/24",
  "10.0.2.0/24",
  "10.0.4.0/24",
  "10.0.6.0/24",
  "172.16.0.0/25",
  "172.16.0.128/25",
  "192.0.2.0/26",
  "192.0.2.64/26",
  "203.0.113.0/27",
  "203.0.113.32/27",
  "198.51.100.0/28",
  "198.51.100.16/28",
  "198.51.100.32/28",
  "198.51.100.48/28",
  "8.8.8.0/24",
  "8.8.10.0/24",
  "192.168.100.0/26",
  "192.168.100.64/26",
  "192.168.100.128/26",
  "192.168.100.192/26",
  "192.168.101.0/27",
  "192.168.101.32/27",
  "192.168.101.64/27",
  "192.168.101.96/27",
  "192.168.101.128/27",
  "10.1.0.0/24",
  "10.1.1.0/24",
  "10.1.2.0/24",
  "172.16.1.0/25",
  "172.16.1.128/25",
  "192.168.50.0/27",
  "192.168.50.32/27",
  "192.168.50.64/27",
  "192.168.50.96/27",
  "203.0.113.64/27",
  "203.0.113.96/27",
  "203.0.113.128/27",
  "203.0.113.160/27",
  "203.0.113.192/27",
  "203.0.113.224/27",
  "198.51.100.64/28",
  "198.51.100.80/28",
  "198.51.100.96/28",
  "198.51.100.112/28",
  "8.8.4.0/24",
  "8.8.5.0/24",
  "1.1.1.0/24",
  "1.1.2.0/24",
  "1.1.3.0/24",
  "9.9.9.0/28",
  "9.9.9.16/28",
  "9.9.9.32/28",
  "9.9.9.48/28",
  "192.168.200.0/25",
  "192.168.200.128/25",
  "192.168.201.0/26",
  "192.168.201.64/26",
  "192.168.201.128/26",
  "192.168.201.192/26",
  "192.168.202.0/27",
  "192.168.202.32/27",
  "192.168.202.64/27",
  "192.168.202.96/27",
  "192.168.202.128/27",
  "192.168.202.160/27",
  "192.168.202.192/27",
  "192.168.202.224/27",
  "172.31.0.0/24",
  "172.31.1.0/24",
  "172.31.2.0/24",
  "172.31.3.0/24",
  "192.168.250.0/25",
  "192.168.250.128/25",
  "192.168.251.0/26",
  "192.168.251.64/26",
  "192.168.251.128/26",
  "192.168.251.192/26",
  "192.168.252.0/24",
  "192.168.253.0/24",
  "192.168.254.0/24",
}

// 集約後
[
  "1.1.1.0/24",
  "1.1.2.0/23",
  "8.8.4.0/23",
  "8.8.8.0/24",
  "8.8.10.0/24",
  "9.9.9.0/26",
  "10.0.0.0/24",
  "10.0.2.0/24",
  "10.0.4.0/24",
  "10.0.6.0/24",
  "10.1.0.0/23",
  "10.1.2.0/24",
  "172.16.0.0/23",
  "172.31.0.0/22",
  "192.0.2.0/25",
  "192.168.0.0/24",
  "192.168.1.0/26",
  "192.168.1.96/27",
  "192.168.2.0/26",
  "192.168.2.64/28",
  "192.168.3.0/24",
  "192.168.50.0/25",
  "192.168.100.0/24",
  "192.168.101.0/25",
  "192.168.101.128/27",
  "192.168.200.0/23",
  "192.168.202.0/24",
  "192.168.250.0/23",
  "192.168.252.0/23",
  "192.168.254.0/24",
  "198.51.100.0/25",
  "203.0.113.0/24",
]

Pythonならipaddress.collapse_addressesを使う

PythonにはCIDRをそのまま受け入れて、また集約する関数ipaddress.collapse_addressesがあります。使い方は以下の通りです。さすがに標準であるのは便利。

import ipaddress

# 入力のCIDRリスト
cidr_list = [
    "192.168.0.0/24",
    "192.168.1.0/24",
    "192.168.10.0/24"
]

# CIDRの集約
networks = [ipaddress.IPv4Network(cidr) for cidr in cidr_list]
merged_networks = list(ipaddress.collapse_addresses(networks))

# 結果の出力
for net in merged_networks:
    print(net)
# 192.168.0.0/23
# 192.168.10.0/24

まとめ

よくサービスのIPアドレスをホワイトリストとして公開する例を見ますが、だいたいCIDRがまとまってないです。 そもそもCIDRをまとめた状態で公開してほしい、と思いつつアドレスがすっと増減した時に集約を外すのがいやなんだろうなぁ、と予想できるのでしょうがない。


  1. 単に「隣接している」だけではなく、「集約後のプレフィックス長に対応するビットがすべて0である」必要があります。たとえば、10.0.100.0/2010.0.115.0/20はまとめられません
  2. IPv6を対応するなら、uintの代わりにBigIntegerへ変更、32bitではなく128bitで計算する必要があります。