Skip to content
URIAH CLEMMER
$ cat blog/building-netcalc-why-the-maths-lives-on-the-server.md

Building NetCalc: why the maths lives on the server

SOFTWARE NETWORKING 9 min read

The IPv6 post ended with a calculator, NetCalc, and a paragraph about what it does. This is the other half: how it does it, and the handful of decisions that shaped everything else. Most of them are small. Two of them are the reason the tool is worth writing about at all.

Every sum runs on the server

Every other subnet calculator on the internet is a page of JavaScript, and for good reason — the maths is trivial and a round trip to do it feels absurd. NetCalc does it in PHP anyway, and results come back through the query string. Two reasons, in order.

It can be tested. This site's suite is Pest, and a Pest test can call SubnetPlanner::summarize('10.0.0.5', '10.0.0.30') and assert the seven blocks that come back. It cannot call a function in a browser. Adding a JavaScript test runner to a site that ships no framework would have been a new dependency in service of a calculator, and a calculator whose maths is not under test is a calculator I would not link from a post about getting subnet sizes right.

The results are linkable. Because the server does the work, the query string is the entire state of the page: /tools/netcalc?cidr=10.0.0.0/22&hosts=50,20,10,2 is a URL you can paste into a ticket, and whoever opens it sees the plan, not an empty form. A client-side calculator can be made to do this. Most are not.

The cost is a page load per calculation, and it is real. If it ever becomes annoying, the fix is progressive enhancement — JavaScript mirroring the PHP for instant feedback while the PHP stays the source of truth — and not moving the logic. That sentence is in the tool's documentation so that whoever gets annoyed first, possibly me, does not reach for the obvious thing.

Addresses are bytes, not integers

The first instinct when writing IPv4 maths is to turn the address into a 32-bit integer, because then a netmask is a shift and a network address is a single AND. It works beautifully, right up until IPv6, where the address is 128 bits and PHP's integers are 64. At that point you either write the IPv6 half again with a bignum library, or you go back and do it differently.

NetCalc does it differently from the start. An address is held exactly as inet_pton() returns it — a binary string of four bytes or sixteen — and every operation walks the bytes. The mask for a prefix is a run of 0xFF bytes, then at most one partial byte, then zeros; applying it is a loop that ANDs each byte of the address with the matching byte of the mask.

ONE BYTE AT A TIME: 10.1.7.42 /22 byte 0 byte 1 byte 2 byte 3 address mask /22 AND 10.1.7.42 255.255.252.0 10.1.4.0 00001010 00000001 00000111 00101010 11111111 11111111 11111100 00000000 00001010 00000001 00000100 00000000 10 1 4 0 bit 22 Only the byte the boundary falls in needs a partial mask: everything before it is 0xFF, everything after it is 0x00. An IPv6 address is the same loop over sixteen bytes. No step of it needs a number wider than eight bits.
for ($i = 0; $i < strlen($bytes); $i++) {
    $bytes[$i] = chr(ord($bytes[$i]) & ord($mask[$i]));
}

That loop does not know which family it is looking at, and does not need to. The broadcast address is the same walk with OR against the inverse mask. "Does this block contain that address" is a mask and a string comparison. Incrementing to the next block is schoolbook addition with a carry, byte by byte from the right. One code path serves both families, and IPv6 support stopped being a feature and became a consequence: nothing anywhere in the tool needs a number wider than eight bits — with one exception.

Counting to 2128 without a bignum

The exception is counts. A /64 holds 18,446,744,073,709,551,616 addresses, which is one more than a signed 64-bit integer can express, and ::/0 holds 2128, which is not close. The tool reports both, exactly, and it does it without GMP or BCMath, because neither extension is guaranteed on a shared host and requiring one for a single label on a page would be out of proportion.

So there is a small class that does decimal arithmetic on arrays of digits. It knows how to do exactly three things: double a number (so 2n is "start at 1, double n times"), subtract a small integer from it (so usable hosts is total minus two), and compare two of them. Each is a dozen lines and each is tested at the top of the range — the suite asserts the full 39-digit value of 2128 as a string, character for character, because "it looks about right" is exactly the wrong standard for a number nobody can check by eye.

$digits = [1];
for ($i = 0; $i < $exponent; $i++) {
    $carry = 0;
    foreach ($digits as $position => $digit) {
        $doubled = $digit * 2 + $carry;
        $digits[$position] = $doubled % 10;
        $carry = intdiv($doubled, 10);
    }
    while ($carry > 0) { $digits[] = $carry % 10; $carry = intdiv($carry, 10); }
}

It is slow in the way that does not matter — 128 doublings of a 39-digit array is nothing — and it means the tool's only dependency for IPv6 is PHP itself.

The /31 decision, and the checkbox that undoes it

Ask the VLSM planner for a subnet with two hosts and it hands you a /31. The textbook answer is a /30. The textbook is out of date: RFC 3021 has said since 2000 that a point-to-point link has exactly two ends and needs neither a network address nor a broadcast address, so both addresses in a /31 are usable, and every router made this century agrees. Defaulting to /30 would have been defaulting to wasting half of every link.

The consequence is easy to miss, and it is the sort of thing that only shows up when you write the table out. Because a /31 always fits before a /30 does, no host count ever returns a /30 in the default mode: two hosts get a /31, and three hosts skip straight to a /29, because a /30 only has two usable addresses to offer them. The tool's own documentation calls this out, because asking for three hosts and getting a /29 reads as a bug the first time you see it.

Hosts neededPoint-to-point allowed/30 floorUsable
1/32/301 · 2
2/31/302 · 2
3/29/296
7/28/2814
15/27/2730
50/26/2662
100/25/25126
254/24/24254
500/23/23510

But the right answer for a router link is the wrong answer for a two-device segment that might gain a third device next year, so the pre-RFC-3021 rule is reachable too. The planner takes a flag; the page exposes it as a checkbox. The mechanics of that checkbox are the one bit of form trivia worth passing on: an unticked checkbox submits nothing at all, so "absent" and "unticked" look identical to the server. The form pairs it with a hidden field that always sends a value, and the controller treats a request with no parameter — a link somebody built by hand, including every link in the earlier post — as the RFC 3021 default. The old links keep answering the way they always did, and the new behaviour is only reachable by asking for it.

A range into the fewest blocks

The operation I actually wanted the tool for is the one most calculators skip: take an arbitrary start and end address and produce the smallest set of CIDR blocks that covers exactly that range, and nothing outside it. Firewall rules want this. BGP prefix lists want this. Nobody wants to do it by hand.

10.0.0.5 – 10.0.0.30 AS THE FEWEST CIDR BLOCKS .0 .4 .8 .12 .16 .20 .24 .28 .31 from .5 to .30 .5/32 .6/31 .8/29 .16/29 .24/30 .28/31 .30/32 At each step: the largest block that starts on its own boundary and does not overshoot the end. Seven blocks for 26 addresses.

The algorithm is greedy and it is correct. Stand at the start address. Find the largest block that both starts on its own alignment boundary — a /29 has to begin on a multiple of eight, a /28 on a multiple of sixteen — and does not reach past the end of the range. Emit it, step to the address after it, repeat until you pass the end. For 10.0.0.5 to 10.0.0.30 that is seven blocks: the blocks grow through the middle of the range as the alignment loosens, and shrink again toward the end as the ceiling binds. Twenty-six addresses, seven rows, and none of them a single address too many.

Two guards sit around that loop, and both exist because of an input somebody will eventually type. The walk stops when the block it just emitted ends at the top of the address space, because incrementing past 255.255.255.255 wraps to 0.0.0.0 and the loop would otherwise run forever. And every planning operation caps its output at 256 rows: splitting a /8 into /30s is 4,194,304 subnets, and the page reports that total and says it is showing the first 256 rather than either hanging or silently truncating.

The test for this one is the kind I like best. Beyond checking a handful of ranges against the blocks they should produce, it takes the output of a summarisation and checks the property that makes the answer an answer: that the blocks abut with no gap and no overlap, that the first starts on the start address, and that the last ends on the end address. A wrong algorithm that happened to produce the right number of blocks would still fail it.

Three small things

Dotted masks are input. 192.168.4.99/255.255.252.0 parses, because that is what ipconfig prints and people paste what they see. A mask with a hole in it — 255.255.0.255 — is rejected rather than guessed at, since there is no prefix length it could honestly mean.

Your address comes from the connection. The page reports the address the request arrived from, read off the socket, never from a third-party lookup, so nothing about you is sent anywhere to be looked up. If something in front of the app added an X-Forwarded-For header the page shows that too, deliberately: a proxy the application does not trust makes the connection address lie, and showing both is more honest than picking one.

The reference tables are data. The commands and applications lists at the bottom of the page are a PHP array, not markup, so a test can walk every entry and assert nothing in it is quietly blank.

What that bought

The whole calculation layer is about a thousand lines with no framework in it — it does not know Laravel exists — and roughly fifty tests over it, half of them at edges: a /0, a /32, a /31, the whole of IPv6, a range ending at the top of the space, a mask with a hole. It has answered every question I have put to it correctly, which is a lower bar than it sounds, and it has been wrong in exactly the way I would want: when it is, there is a test to write, and the test will stay.

If you find a number it gets wrong, tell me. That offer stands from the last post, and it is a better one now that I have shown you where to look.

#networking #netcalc #php #testing
CONTINUE? Questions about this one? Say hello →