Address math, walked through
The 36 offline tools are all arithmetic, which means every answer they give is checkable by hand. This page walks the pieces that are easy to get subtly wrong, with the rule, the code, and a worked example.
Every number in the worked examples below was produced by calling the function it illustrates and reading the result, not by doing the arithmetic in prose. If one of them is wrong, the code is wrong too, which is the point.
IPv4 subnetting, and the /31 and /32 special cases
Section titled “IPv4 subnetting, and the /31 and /32 special cases”calculateIPv4Subnet does the whole thing in 32-bit integer space:
const ipInt = ipv4ToInt(ip)const maskInt = prefixToMaskInt(prefix)const networkInt = (ipInt & maskInt) >>> 0const broadcastInt = (networkInt | (~maskInt >>> 0)) >>> 0The >>> 0 on every line is not decoration. JavaScript bitwise operators produce signed 32-bit results, so without the unsigned shift a mask with the high bit set comes back negative and every subsequent comparison is wrong.
Worked, for 192.168.1.130/26:
ip = 192.168.1.130 -> 11000000 10101000 00000001 10000010mask /26 = 255.255.255.192 -> 11111111 11111111 11111111 11000000
network = ip AND mask -> 11000000 10101000 00000001 10000000 = 192.168.1.128wildcard = NOT mask -> 00000000 00000000 00000000 00111111 = 0.0.0.63broadcast = network OR wc -> 11000000 10101000 00000001 10111111 = 192.168.1.191
firstHost = network + 1 = 192.168.1.129lastHost = broadcast - 1 = 192.168.1.190hostCount = 2^(32-26) - 2 = 64 - 2 = 62The - 2 is the network and broadcast addresses. That subtraction is exactly what breaks at long prefixes, and the code special-cases both:
| Prefix | firstHost |
lastHost |
hostCount |
Why |
|---|---|---|---|---|
/24 |
network + 1 |
broadcast - 1 |
2^8 - 2 = 254 |
the general case |
/31 |
network |
broadcast |
2 | RFC 3021 section 2.1 |
/32 |
network |
network |
1 | a host route, not a subnet |
RFC 3021 is the interesting one. On a point-to-point link there is nobody to broadcast to: a packet sent by one end can only reach the other. So the two addresses in a /31 are both usable, and the general formula would return 2 - 2 = 0 usable hosts for a prefix that router vendors have supported for two decades. Worked out completely:
address 192.0.2.4/31network 192.0.2.4 # 2 addresses totalbroadcast 192.0.2.5
general formula 2^(32-31) - 2 = 2 - 2 = 0 usable # wrong, and a /31 is legalrfc 3021 s2.1 both addresses are host addresses = 2 usable the "broadcast" address is just the peer
so firstHost = network = 192.0.2.4 lastHost = broadcast = 192.0.2.5Getting this wrong is the classic subnet-calculator bug. Three cases in tests/unit/network-utils.test.ts pin the boundary from all sides: “handles /31 per rfc 3021”, “handles /32 as a single host”, and “handles /0 as the whole address space”. The same rule is enforced independently for address enumeration by “treats a /31 as a point-to-point link per rfc 3021” in tests/unit/ip-enumerate.test.ts, because two code paths agreeing by accident is not the same as two code paths being tested.
One deliberate choice worth flagging: address classification runs against the host address, not the network address. 192.168.1.5/8 has network 192.0.0.0, which is not private, but the address you typed is still RFC 1918 section 3 space. The comment in the code says so. The classifier covers 10/8, 172.16/12, 192.168/16, loopback, link-local and multicast; it does NOT cover 100.64.0.0/10 shared address space (RFC 6598) or the documentation ranges in the IANA IPv4 Special-Purpose Address Registry, so a CGNAT address is reported as public.
The shift count is an integer whether or not you gave it one
Section titled “The shift count is an integer whether or not you gave it one”prefixToMaskInt is four lines long and has now been the site of two separate bugs of the same family, both caused by the same clause of the language specification. It is worth reading closely, because the fix for the second one is a single call to Number.isInteger and the failure it prevents is silent wrong output rather than a crash.
export function prefixToMaskInt(prefix: number): number { if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) { throw new Error("Invalid prefix length (must be 0-32)") } return prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0}ECMA-262, the left shift operator does two things to its operands before shifting anything:
mask = ToInt32(lhs) << (ToUint32(rhs) mod 32)
where: lhs = 0xffffffff, the all-ones mask rhs = 32 - prefix, the number of host bits ToInt32 = truncate toward zero, reduce modulo 2^32, reinterpret as signed ToUint32 = truncate toward zero, reduce modulo 2^32 mod 32 = the shift count is reduced again, to the low five bitsBoth of those are lossy, and each one ate a prefix.
mod 32 is the older bug, and it is named in the comment above the function: 0xffffffff << 32 is 0xffffffff << 0, so a /0 produced the mask 255.255.255.255. Every default route came out as a host route, and network 0.0.0.0 255.255.255.255 is not an OSPF statement any device will accept. The prefix === 0 ? 0 : branch is that fix.
ToUint32 is the newer one, and it is nastier because there is no boundary to notice. A fractional prefix passes a range check, then truncates in the shift count, so the effective prefix silently becomes ceil(prefix):
effective = 32 - trunc(32 - prefix) = ceil(prefix) for any prefix in [0, 32]Worked, on the two values the test uses:
prefixToNetmask(24.5) 32 - 24.5 = 7.5 ToUint32(7.5) = 7 # truncated, not rounded 0xffffffff << 7 -> 25 leading ones mask = 255.255.255.128 # a /25, for a /24-ish request
prefixToNetmask(23.1) 32 - 23.1 = 8.899999999999999 # binary64, not 8.9 ToUint32(...) = 8 0xffffffff << 8 -> 24 leading ones mask = 255.255.255.0 # a /24, for a /23-ish requestNote the direction. Truncating the host count rounds the prefix up, so every fractional input produced a mask tighter than the one asked for, and a config generator emitting a /25 where you typed 24.5 produces a subnet that is legal, plausible, and half the size you intended. That is the failure mode this whole page exists for: there was no exception, no NaN, and no boundary case to trip a reviewer.
What it does now is throw, rather than clamp or round, because there is no defensible rounding direction for a prefix that a user typed wrong. The message is Invalid prefix length (must be 0-32), with no trailing period, and tests/unit/network-utils.test.ts asserts it in “rejects a fractional prefix instead of silently rounding it” across 24.5, 24.9, 23.1 and 0.5. The assertion matches on the must be 0-32 substring, so rewording the message fails the test rather than weakening it.
You can reproduce the old behaviour in any JavaScript runtime, which is the cheapest way to convince yourself the mechanism is the language rather than the app:
# count the leading ones the old expression produced for a prefix of 24.5node -e 'const p=24.5; console.log(((0xffffffff << (32 - p)) >>> 0).toString(2).replace(/0+$/, "").length)'# 25The blast radius of the guard is wider than the one function, because everything that derives a mask goes through it: calculateIPv4Subnet, lib/ip-enumerate.ts, lib/mask-convert.ts and the four route generators in lib/routing.ts. calculateIPv4Subnet keeps only its own range check and did not gain an integer check of its own, so it is protected indirectly, by the call to prefixToMaskInt on the line after.
The IPv6 sibling ipv6NetworkPrefix already had Number.isInteger in its guard, which is how the asymmetry was found.
RFC 5952 compression, and why the leftmost longest run wins
Section titled “RFC 5952 compression, and why the leftmost longest run wins”RFC 5952 section 4.2 gives three rules that together make IPv6 text canonical, and they matter because two spellings of the same address must compare equal:
| Rule | Section | Requirement |
|---|---|---|
| Shorten as much as possible | 4.2.1 | :: must be used wherever it can be |
| Never shorten a single group | 4.2.2 | 2001:db8:0:1::1 keeps that lone 0 |
| Longest run, then leftmost | 4.2.3 | on a tie, the first run is the one shortened |
| Lowercase hex | 4.3 | 2001:db8 not 2001:DB8 |
compressIPv6 implements all four. The tie-break is one character:
if (currentZeroLength > longestZeroLength) { longestZeroStart = currentZeroStart longestZeroLength = currentZeroLength}Strictly greater, not greater-or-equal. Scanning left to right, a later run of equal length therefore never displaces the one already recorded, which is precisely rule 4.2.3. Change that > to >= and the function silently starts producing non-canonical output that still looks plausible.
Worked, on the classic ambiguous address:
input 2001:0db8:0000:0000:0001:0000:0000:0001groups [2001][0db8][0000][0000][0001][0000][0000][0001] ^^^^^^^^^^ ^^^^^^^^^^runs run A at index 2, length 2 run B at index 5, length 2
tie on length -> rule 4.2.3 keeps the leftmost -> compress run A
output 2001:db8::1:0:0:1 # correctnot 2001:db8:0:0:1::1 # what >= would have producedAnd rule 4.2.2 in action:
input 2001:0db8:0000:0001:0000:0000:0000:0001runs length 1 at index 2, length 3 at index 4longest the length-3 run wins outright, no tieoutput 2001:db8:0:1::1 # the lone zero stays spelled outThe >= 2 guard on the compression branch is what enforces 4.2.2: a run of one is never eligible. The behaviour is pinned by four cases in tests/unit/network-utils.test.ts, each named after the rule it defends: “compresses the leftmost longest zero run on ties”, “never compresses a single zero group”, “lowercases hex per rfc 5952 4.3”, and “throws a clear error instead of RangeError past 8 groups”.
Three more details in the same file.
splitIPv6Zone peels off the %eth0 scope identifier before parsing and reattaches it after, because a zone ID is part of the textual form under RFC 4007 section 11 and not part of the address.
expandIPv6 converts the IPv4-embedded form of RFC 4291 section 2.5.5, so ::ffff:192.168.1.1 becomes eight hex groups rather than failing to parse.
And compressIPv6 starts with an early return through ipv4MappedText, which implements the rule the other three rules do not cover. RFC 5952 section 5 says an address with an embedded IPv4 address keeps the dotted quad in the text form, so the canonical spelling is ::ffff:192.0.2.1 and not ::ffff:c000:201. The subtlety is the scope: that rule applies to the IPv4-mapped block ::ffff:0:0/96, not to every address whose last 32 bits could be read as a dotted quad. Two tests draw exactly that line, “keeps the dotted quad for ipv4-mapped addresses (rfc 5952 5)” and “applies the dotted quad only to ::ffff:0:0/96”.
This also explains a note in the DNS code. The AAAA answers coming back from a resolver are recompressed through this function, because the underlying codec compresses the first zero run rather than the longest, and its output is therefore not canonical. The test that catches a regression there is “compresses the leftmost longest run, not the first one” in tests/unit/network-testing.test.ts.
Modified EUI-64, and the bit that gets flipped
Section titled “Modified EUI-64, and the bit that gets flipped”RFC 4291 Appendix A derives a 64-bit interface identifier from a 48-bit MAC address. Two steps: insert FFFE in the middle, and invert the universal/local bit.
There are two implementations, and it is worth knowing which you are looking at. eui64InterfaceId in lib/ipv6-address.ts is the one behind the IPv6 tools; generateEUI64FromMAC is the older one still wired to the Network Tester’s IPv6 panel. They agree, and the duplication is a real rough edge rather than a design. The second one is quoted here because it does the prefix assembly too:
const firstOctet = Number.parseInt(firstHalf.slice(0, 2), 16)const flippedOctet = (firstOctet ^ 0x02).toString(16).padStart(2, "0")const eui64 = (flippedOctet + firstHalf.slice(2) + "FFFE" + secondHalf).toLowerCase()^ 0x02 is the whole story, and it is worth understanding why it is 0x02 and not 0x40. In IEEE 802 MAC transmission order the universal/local bit is the second-least-significant bit of the first octet, so as a hex byte it is mask 0x02. In the IPv6 interface identifier that same bit is defined by RFC 4291 section 2.5.1 with inverted meaning: 1 means globally unique. Hence an XOR rather than a set or a clear, so the transform is its own inverse.
Worked, for 00:1A:2B:3C:4D:5E under prefix 2001:db8::/64:
mac 00 1a 2b 3c 4d 5efirst octet 0x00 = 0000 0000xor 0x02 0x02 = 0000 0010 # u/l bit flipped: locally scoped MAC -> global IIDinsert fffe 02 1a 2b ff fe 3c 4d 5eas groups 021a:2bff:fe3c:4d5e
prefix 2001:db8:: -> expand -> 2001:0db8:0000:0000first 4 2001:0db8:0000:0000address 2001:0db8:0000:0000:021a:2bff:fe3c:4d5ecompress 2001:db8::21a:2bff:fe3c:4d5eA counter-example, because the flip goes both ways. 02:1A:2B:3C:4D:5E already has the bit set, so 0x02 ^ 0x02 = 0x00 and the identifier becomes 001a:2bff:fe3c:4d5e. If you see an identifier starting 00 you are looking at a MAC that was locally administered.
The prefix is run through expandIPv6 before its first four groups are taken, which is why a compressed input like 2001:db8:: contributes the right number of zero groups instead of three groups and a gap.
The test names here are unusually specific on purpose, because “it produces an address” would pass for several wrong implementations. tests/unit/ipv6-address.test.ts asserts “inverts the u/l bit, not some other bit”, and “refuses a prefix longer than /64, which leaves no room for the iid”, which is the constraint that follows from an interface identifier being 64 bits wide. tests/unit/network-testing.test.ts covers the older copy with “flips the universal/local bit and inserts ff:fe” and “expands compressed prefixes and builds the modified eui-64 address”.
Solicited-node multicast
Section titled “Solicited-node multicast”RFC 4291 section 2.7.1 defines the solicited-node address as ff02::1:ff00:0/104 with the low 24 bits of the target address appended. Neighbour Discovery uses it so a neighbour solicitation reaches one host instead of every host on the link (RFC 4861 section 7.2.1).
solicitedNodeMulticast takes the last two groups of the expanded address and masks them:
const upper = Number.parseInt(groups[6], 16) & 0xff // low byte of group 7const lower = Number.parseInt(groups[7], 16) & 0xffff // all of group 8// upper is padded because it is the low byte of the group "ff" + XX, not a// leading zero; lower is a whole group, so rfc 5952 4.1 forbids padding itreturn `ff02::1:ff${upper.toString(16).padStart(2, "0")}:${lower.toString(16)}`0xff on the upper group plus 0xffff on the lower is 8 + 16 = 24 bits, which is the /104 remainder exactly. Worked:
target 2001:db8::21a:2bff:fe3c:4d5eexpanded 2001:0db8:0000:0000:021a:2bff:fe3c:4d5egroups[6] fe3c -> & 0xff -> 0x3cgroups[7] 4d5e -> & 0xffff -> 0x4d5eresult ff02::1:ff3c:4d5eThe two padStart calls are not the same decision
Section titled “The two padStart calls are not the same decision”This looks like inconsistency and is not, and the asymmetry is the most easily-broken line in the file.
upper is padded to two hex digits because it is not a group. It is the low byte of a group whose high byte is the literal ff that makes the address solicited-node. Writing ff + 3 instead of ff + 03 shifts every subsequent nibble and produces a different address.
lower is not padded, because it is a whole 16-bit group, and RFC 5952 section 4.1 requires leading zeros in a group to be suppressed. Padding it would emit a legal-but-non-canonical spelling that fails a string comparison against the same address written correctly.
Worked, on an address that exercises both:
target 2001:db8::1:2:34:5expanded 2001:0db8:0000:0000:0001:0002:0034:0005groups[6] 0034 -> & 0xff -> 0x34 -> "34" # already two digitsgroups[7] 0005 -> & 0xffff -> 0x5 -> "5" # NOT "0005"result ff02::1:ff34:5
and one where the upper byte needs its pad:target 2001:db8::1:2:3:4567groups[6] 0003 -> & 0xff -> 0x3 -> "03" # padded, or "ff3" is wronggroups[7] 4567 -> & 0xffff -> 0x4567 -> "4567"result ff02::1:ff03:4567Both directions are held by named tests in tests/unit/ipv6-address.test.ts: “sits inside ff02::1:ff00:0/104 and carries the low 24 bits”, and “suppresses leading zeros as rfc 5952 4.1 requires”.
There was also a real bug here. The comment in lib/network-testing.ts records that a second copy of this function mis-sliced unpadded groups, so an address whose group 7 rendered as 3c instead of 003c produced the wrong multicast address. There is now one implementation in lib/network-utils.ts, generateSolicitedNodeMulticast in lib/network-testing.ts is a re-export shim under the old name, and “delegates to the shared network-utils implementation” in tests/unit/network-testing.test.ts asserts the shim stays a shim.
MTU, MSS, and the three floors
Section titled “MTU, MSS, and the three floors”The MTU Calculator runs on lib/mtu.ts. Its opening comment states the design rule: every byte count is decomposed and cited, “because a flat ‘overhead’ number hides which layer and which cipher it assumed.”
The formula
Section titled “The formula”effectiveMTU = linkMTU - SUM(encapsulation bytes)payloadBytes = effectiveMTU - ipHeader - transportHeadertcpMSS = effectiveMTU - ipHeader - TCP_HEADER
where: linkMTU = the layer-2 MTU of the path, 1500 on standard Ethernet encapsulation = every outer layer wrapped around the inner IP packet effectiveMTU = what the inner IP layer sees; the RFC floors apply HERE ipHeader = 20 for IPv4 (RFC 791 s3.1), 40 for IPv6 (RFC 8200 s3) transportHeader= 20 for TCP (RFC 9293 s3.1), 8 for UDP (RFC 768), 0 for none TCP_HEADER = always 20, even when the selected transport is UDPThe last line is deliberate. tcpMSS is reported whatever transport you picked, because the question “what MSS should I clamp to on this path” is the one an operator actually has, and it does not stop being relevant because the tool is currently set to UDP. The test is titled “still reports a TCP MSS when the selected transport is UDP”.
The comparison target for the floors is effectiveMTU, not payloadBytes. That is the correction most calculators get wrong: RFC 8200 section 5 is a statement about the link, so subtracting TCP before comparing would fire the error on paths that are perfectly legal. Two tests draw that line explicitly: “errors only once the effective MTU itself drops below 1280”, and “does not fire on a link that is legal but leaves a payload under 1280”.
The three floors
Section titled “The three floors”| Constant | Value | Severity | Source |
|---|---|---|---|
IPV6_MIN_LINK_MTU |
1280 | error | RFC 8200 section 5: every link must carry 1280 octets |
IPV4_MIN_MTU |
68 | error | RFC 791 section 3.2: 60-octet maximum header plus an 8-octet minimum fragment |
IPV4_MIN_REASSEMBLY |
576 | warning | RFC 791 section 3.2 and RFC 1122 section 3.3.2: every host must reassemble this much |
576 is a warning rather than an error because it is a statement about what a host must be able to accept, not about what a link may carry. A 300-byte IPv4 link is legal and works; it is just a place where something will eventually surprise you. “stays silent at exactly 576 even though the payload is smaller” is the boundary test.
The default MSS values are carried too, from RFC 9293 section 3.7.1: 536 for IPv4 and 1220 for IPv6. Those are what a peer assumes when no MSS option is exchanged, so they are the floor your path is being compared against.
The encapsulation table
Section titled “The encapsulation table”ENCAPSULATIONS holds nine profiles. Each detail string names its own components, so the number is auditable rather than folded:
| Id | Label | Bytes | Decomposition, from the detail string |
|---|---|---|---|
dot1q |
802.1Q VLAN tag | 4 | 2-byte TPID plus 2-byte TCI. Costs MTU only on gear that cannot carry a 1522-byte baby-giant frame |
qinq |
802.1ad QinQ double tag | 8 | S-TAG plus C-TAG, 4 bytes each |
pppoe |
PPPoE | 8 | 6-byte PPPoE header plus 2-byte PPP protocol id (RFC 2516 section 7) |
gre |
GRE over IPv4 | 24 | 20-byte delivery IPv4 header plus 4-byte GRE (RFC 2784 section 2.1); add 4 per optional key or sequence field, 44 for an IPv6 delivery header |
vxlan |
VXLAN over IPv4 | 50 | 20 outer IPv4 + 8 outer UDP + 8 VXLAN + 14 inner Ethernet (RFC 7348 section 5); an IPv6 outer costs 70 |
esp-gcm |
IPsec ESP tunnel, AES-GCM | 57 | 20 outer IPv4 + 8 ESP + 8 IV (RFC 4106 section 3) + 2 trailer + 16 ICV (section 6) + up to 3 padding |
esp-cbc |
IPsec ESP tunnel, AES-CBC HMAC-SHA1-96 | 73 | 20 outer IPv4 + 8 ESP + 16 IV (RFC 3602 section 3) + 2 trailer + 12 ICV + up to 15 padding to the 16-byte block (RFC 4303 section 2.3) |
nat-t |
IPsec NAT-T UDP encapsulation | 8 | 8-byte UDP header wrapping ESP (RFC 3948) |
mpls |
MPLS label | 4 | one 4-byte label stack entry (RFC 3032 section 2.1); add 4 per extra label |
Two things that table gets right and a flat “IPsec = 50” does not. The ESP figures are split by cipher, because AES-GCM and AES-CBC genuinely differ by 16 bytes, and both are labelled worst case because the padding is variable. And the encapsulation figure is the reduction in the inner IP MTU for a given outer layer-3 MTU, which is the number you type into a tunnel interface, rather than a frame-size delta you then have to convert.
Worked, on a real tunnel stack
Section titled “Worked, on a real tunnel stack”Every figure below was produced by calling calculateMTU and reading its output, not by adding up the table by hand:
1500-byte Ethernet, IPv6 over TCP, no encapsulation effectiveMTU 1500 - 0 = 1500 payloadBytes 1500 - 40 - 20 = 1440 tcpMSS 1440 # the classic IPv6 number # the IPv4 equivalent is 1460
VXLAN overlay on the same path encapsulation 50 effectiveMTU 1450 # >= 1280, no error payloadBytes 1450 - 40 - 20 = 1390 tcpMSS 1390
Same overlay over a PPPoE access link linkMTU 1492 # 1500 - 8, which is why DSL is 1492 effectiveMTU 1442 payloadBytes 1382 # still clear of the floor
Now add an AES-GCM IPsec tunnel underneath the overlay encapsulation 50 + 57 = 107 effectiveMTU 1500 - 107 = 1393 # 113 bytes of headroom left payloadBytes 1333 tcpMSS 1333The last stack is the point. It is a completely ordinary design, and it has 113 bytes of margin above the IPv6 floor. Add a single VLAN tag and a NAT-T header, and effectiveMTU reaches 1381; add an IPv6 outer header on the VXLAN instead of an IPv4 one, and the extra 20 bytes take you to 1361. Keep going and IPv6 stops passing inside the tunnel while IPv4 keeps limping along fragmenting, which is the failure mode that presents as “the VPN is slow for some sites” and takes a week to find. That transition is what the error exists to catch, and it is why the threshold is an IPv6 constant rather than a percentage.