DEV Community

yuanke215
yuanke215

Posted on Originally published at ipcalcplus.com AI-assisted

Your Firewall Speaks CIDR, Your Vendor Speaks IP Ranges

A vendor sends you the access list to whitelist: "allow 203.0.113.16 through 203.0.113.79."

Cool. You paste it into your security group and the console politely refuses. Firewalls, cloud security groups, route tables, and ACLs all want CIDR blocks — a network address and a prefix length — not a start and end address.

So how many blocks is that range, exactly? And why can't it ever be one?

Why one range means several blocks

A CIDR block is rigid in two ways:

  1. It always spans a power of two number of addresses (/24 = 256, /23 = 512...).
  2. It always starts at an aligned boundary — a /22 starts at an address whose low 10 bits are zero, a /21 at an address whose low 11 bits are zero, and so on.

Real-world ranges don't respect either rule. 203.0.113.16 - 203.0.113.79 spans 64 addresses (a power of two, good), but it starts at .16, which is only aligned for blocks of 16 addresses. A single /26 at 203.0.113.0 would cover the whole range — but also 16 addresses you were not authorized to allow. Over-permissive by 25%.

The only exact representation is a split. This particular range decomposes into:

CIDR block Covers Addresses
203.0.113.16/28 .16 – .31 16
203.0.113.32/27 .32 – .63 32
203.0.113.64/28 .64 – .79 16

Three blocks, 64 addresses, union exactly equals the range. That's the firewall-safe answer.

The algorithm: greedily take the biggest legal block

The standard decomposition walks the range from its start, and at each step takes the largest block that satisfies both constraints:

def ip_to_int(ip):
    a, b, c, d = (int(x) for x in ip.split("."))
    return (a << 24) | (b << 16) | (c << 8) | d

def int_to_ip(n):
    return ".".join(str((n >> s) & 0xFF) for s in (24, 16, 8, 0))

def low_bit_align(v):
    """Largest power-of-two block size aligned at v."""
    if v == 0:
        return 32
    k = 0
    while v % 2 == 0:
        v //= 2
        k += 1
    return k

def floor_log2(n):
    k = 0
    while n >= 2:
        n //= 2
        k += 1
    return k

def range_to_cidr(start, end):
    blocks = []
    cur = start
    while cur <= end:
        size = min(low_bit_align(cur), floor_log2(end - cur + 1))
        count = 1 << size
        blocks.append((int_to_ip(cur), 32 - size, count))
        cur += count
    return blocks

for net, pfx, count in range_to_cidr(ip_to_int("203.0.113.16"), ip_to_int("203.0.113.79")):
    print(f"{net}/{pfx}  ({count} addresses)")
Enter fullscreen mode Exit fullscreen mode

Each iteration is capped by two limits:

  • Alignment limit — how big a block can start here. low_bit_align counts the trailing zero bits of the current address: 203.0.113.16 ends in four zero bits (16 = 10000 in binary's low positions), so at most a 16-address block can start there.
  • Span limit — how big a block fits before the range's end. The largest power of two not exceeding the remaining addresses.

Take the smaller cap, emit the block, jump past it, repeat. Because each step grabs the biggest legal block, the result is the minimum number of CIDR blocks — no other exact cover uses fewer.

The staircase effect

When a range starts and ends on ragged boundaries, the output looks strange at first:

10.0.0.1  -  10.0.0.254   →

10.0.0.1/32
10.0.0.2/31
10.0.0.4/30
10.0.0.8/29
10.0.0.16/28
10.0.0.32/27
10.0.0.64/26
10.0.0.128/26
10.0.0.192/27
10.0.0.224/28
10.0.0.240/29
10.0.0.248/30
10.0.0.252/31
10.0.0.254/32
Enter fullscreen mode Exit fullscreen mode

Fourteen blocks for 254 addresses! That's not a bug — it's the shape of an exact cover when you exclude the network address (10.0.0.0) and the broadcast address (10.0.0.255) from an otherwise tidy /24. The small blocks nibble the ragged start, big blocks eat the middle, and small blocks finish the ragged tail.

Compare that with the clean version: 10.20.0.0 - 10.23.255.255 decomposes to a single 10.20.0.0/14... if the addresses align. They do here, which is why designing ranges on power-of-two boundaries from day one saves everyone pain later.

Where this conversion actually pays off

  • Security group and firewall rules — the vendor-scope case above. Exact blocks mean you never allow more than you intended.
  • Route summarization — merging site ranges into the shortest prefix list keeps routing tables small.
  • Cloud migrations — moving 10.20.0.0 - 10.23.255.255 into AWS VPC subnets requires an exact decomposition before you can type a single subnet CIDR.
  • IPAM imports — spreadsheets record ranges; DHCP scopes, monitoring systems, and ACLs want prefixes.

The math is small enough to write yourself (you just saw the whole algorithm), but when you're staring at a migration spreadsheet with forty ranges, an IP range to CIDR converter that prints the block list with masks and address counts saves an afternoon of manual binary arithmetic — and unlike a firewall, it won't silently truncate your scope if you get an octet wrong.

Rules of thumb

  1. If your range's length is a power of two and the start is aligned, you get exactly one block. Everything else splits.
  2. Blocks never overlap in a correct decomposition — they tile the range edge to edge.
  3. The block count explodes only at ragged boundaries. Round your allocations to power-of-two boundaries and future conversions collapse to a single line.
  4. Always verify the union covers your range exactly — an extra address in a firewall rule is a security incident waiting for an auditor.

Next time a vendor sends you a range, you'll know in thirty seconds whether it's one clean block, a three-block staircase, or a fourteen-block monster — and exactly what to paste into that security group.

Top comments (0)