An IPv6 address is not a string
Shipped
netwatch 0.2.0 fixed a bug its own first real run found: accept --host fe80: wrote a baseline entry, printed a success table, exited 0, and the entry could never match a single flow. Two independent causes, two layers in the fix: host matching now treats IPv6 as a real address family with 128-bit prefix arithmetic, and accept gained a --snapshot flag that counts what each new entry actually covers and warns, by name, when the answer is zero. Both generalize well past this tool, because the underlying mistake, treating an IPv6 address as a string, is one you can make in any allowlist, firewall helper, or log filter.
Why the string version cannot work
The failed entry looked reasonable. IPv4 prefix rules like 17.253. work fine as startsWith, so fe80: for the link-local range reads like the same idea. It is not, for a reason RFC 5952 spends its whole introduction on: “A single IPv6 address can be text represented in many ways”, and “this flexibility has caused many problems for operators, systems engineers, and customers”. RFC 4291 lets you drop leading zeros in every 16-bit field and compress any one run of zero fields to ::, so 2001:DB8:0:0:8:800:200C:417A and 2001:DB8::8:800:200C:417A are the same address, and hex digits come in both cases. RFC 5952 even documents the failure mode directly, for a spreadsheet search: “If the entry in the spreadsheet reads, 2001:db8::1:0:0:1, but the search was conducted as 2001:db8:0:0:1::1, this will show a result of no match.”
The capture that broke netwatch made it concrete. The live flow’s address was fe80:13::aa35:70b7:7318:8427; the natural way to write the link-local prefix is fe80::, and 'fe80:13::aa35:...'.startsWith('fe80::') is false, because extra bits sit between the prefix and the rest. On top of that, link-local addresses are exactly where exact-match rules rot fastest: RFC 4007 explains that “the same non-global address may be in use in more than one zone of the same scope”, which is why systems track an interface zone alongside the address and why the peer you see today is not guaranteed to be spelled the same tomorrow.
So the primitive has to be bits, not text. Prefix arithmetic does not care how an address was spelled.
Build the matcher
Expand any legal spelling to eight 16-bit groups, then compare the first N bits. Save as v6.js:
export function v6ToGroups(text) {
const t = String(text).toLowerCase();
if (!/^[0-9a-f:]+$/.test(t) || t.includes(':::')) return null;
const halves = t.split('::');
if (halves.length > 2) return null;
const left = halves[0] ? halves[0].split(':') : [];
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const fill = 8 - left.length - right.length;
if (halves.length === 2 ? fill < 1 : left.length !== 8) return null;
const groups = [...left, ...Array(halves.length === 2 ? fill : 0).fill('0'), ...right];
if (groups.some((g) => g.length === 0 || g.length > 4)) return null;
return groups.map((g) => parseInt(g, 16));
}
export function v6InCidr(host, cidr) {
const [base, bitsRaw] = String(cidr).split('/');
const bits = Number(bitsRaw);
if (!Number.isInteger(bits) || bits < 0 || bits > 128) return false;
const h = v6ToGroups(host);
const b = v6ToGroups(base);
if (!h || !b) return false;
for (let i = 0; i < 8; i += 1) {
const remaining = bits - i * 16;
if (remaining <= 0) return true;
const mask = remaining >= 16 ? 0xffff : (0xffff << (16 - remaining)) & 0xffff;
if ((h[i] & mask) !== (b[i] & mask)) return false;
}
return true;
}
Every return null is a refusal to guess: fe80:::1, a group longer than four digits, or an IPv4-mapped form like ::ffff:1.2.3.4 all come back “unparseable”, and unparseable means no match, never a coerced one.
Keep the friendly spelling as sugar
People will still want to type fe80: the way they type 17.253.. Let them, by desugaring it to CIDR instead of comparing text: N complete hextets mean a /(16 × N) prefix, the exact symmetry of trailing-dot IPv4 prefixes where N octets mean /(8 × N):
import { v6InCidr } from './v6.js';
export function hostMatches(rhost, pattern) {
const r = String(rhost).toLowerCase();
const p = String(pattern).toLowerCase();
if (p === r) return true;
if (p.includes('/')) return v6InCidr(r, p);
if (p.endsWith('.')) return r.startsWith(p); // the IPv4 trailing-dot form, kept
if (p.endsWith(':')) {
const groups = p.replace(/:+$/, '').split(':').filter(Boolean);
if (groups.length === 0) return false;
return v6InCidr(r, `${groups.join(':')}::/${groups.length * 16}`);
}
return false;
}
Under this rule fe80: means fe80::/16: exactly the hextets you typed, never wider. A user who wants the architectural link-local range writes fe80::/10, which now works too. Erring narrow is the safe direction for an allowlist, because an over-wide entry silently vouches for flows nobody looked at. And validation must refuse : and :: outright; both desugar to a /0 that matches every IPv6 address on earth, which is the allowlist equivalent of deleting the allowlist.
Warn when a new rule covers nothing
The second fix catches the whole class, including the mistyped IPv4 prefix this post is not about. When accepting an entry, count what it matches right now:
import { hostMatches } from './matcher.js';
export function acceptWithCoverage(entries, newEntry, currentFlows) {
const matches = currentFlows.filter((f) => hostMatches(f.rhost, newEntry.host)).length;
entries.push(newEntry);
if (matches === 0) {
console.warn(`zero-match warning: "${newEntry.host}" matched zero flows in this snapshot; ` +
'the flows you meant to cover will still read unrecognized.');
}
return { entries, matches };
}
Warn, do not refuse: pre-seeding a baseline with a range that is not live right now is legitimate. But exit 0 plus a loud, named warning is a different thing from exit 0 and silence, and the difference is the whole bug.
Verify it against the spellings that bite
node --input-type=module -e "
import { hostMatches } from './matcher.js';
const real = 'fe80:13::aa35:70b7:7318:8427';
for (const p of ['fe80:', 'fe80::/10', 'fe80::/16', '2606:4700::/32'])
console.log(p.padEnd(16), hostMatches(real, p));
console.log('two spellings same address:', hostMatches('fe80::1', 'fe80:0:0:0:0:0:0:1/128'));
"
fe80: true
fe80::/10 true
fe80::/16 true
2606:4700::/32 false
two spellings same address: true
The last line is the assertion that proves the implementation is arithmetic and not startsWith; a string version returns false there, and if it ever goes red you have regressed to text matching.
Gotchas
The idiomatic spelling is the one a naive prefix fix still misses. If you patch this by adding a trailing-colon startsWith branch, fe80: starts working and fe80::, the way most people write link-local, still silently fails against real addresses. You will have shipped the same bug for the most likely input. Desugar to CIDR and there is one primitive underneath both spellings.
The validator has to grow with the matcher. The moment trailing-colon patterns work, : and :: become expressible, and both mean match-everything. netwatch refuses them at validation the same way it already refused *; open a new pattern family and the match-everything spellings of that family come with it.
A dead entry survives every existing test. The broken entry validated, stored, printed success, and the report ran green; nothing anywhere failed, because nothing asked “does this entry match anything”. The zero-match count at accept time is the cheap, mechanical version of that question, and it would also have caught a 17.253 missing its trailing dot, a wrong --process filter, a typo in a port. Coverage checks on rules, not just tests, is the transferable habit.
Sources
- RFC 5952, A Recommendation for IPv6 Address Text Representation — one address, many spellings, and the documented search-mismatch failure
- RFC 4291, IP Version 6 Addressing Architecture — leading-zero and
::compression rules, and thefe80::/10link-local prefix - RFC 4007, IPv6 Scoped Address Architecture — why link-local addresses carry zone context and resist exact matching