From cee08bdd5477824381f12459eba6463fdbdd7fbf Mon Sep 17 00:00:00 2001 From: a0666660 <69061030+a0666660@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:23:29 +0800 Subject: [PATCH] feat(new tool): IPv6 subnet calculator --- src/tools/index.ts | 3 +- src/tools/ipv6-subnet-calculator/index.ts | 12 ++ .../ipv6-subnet-calculator.models.test.ts | 73 ++++++++ .../ipv6-subnet-calculator.models.ts | 159 ++++++++++++++++++ .../ipv6-subnet-calculator.vue | 71 ++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/tools/ipv6-subnet-calculator/index.ts create mode 100644 src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.test.ts create mode 100644 src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.ts create mode 100644 src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.vue diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..84836e31 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -36,6 +36,7 @@ import { tool as httpStatusCodes } from './http-status-codes'; import { tool as yamlToJson } from './yaml-to-json-converter'; import { tool as jsonToYaml } from './json-to-yaml-converter'; import { tool as ipv6UlaGenerator } from './ipv6-ula-generator'; +import { tool as ipv6SubnetCalculator } from './ipv6-subnet-calculator'; import { tool as ipv4AddressConverter } from './ipv4-address-converter'; import { tool as benchmarkBuilder } from './benchmark-builder'; import { tool as userAgentParser } from './user-agent-parser'; @@ -164,7 +165,7 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Network', - components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator], + components: [ipv4SubnetCalculator, ipv6SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator], }, { name: 'Math', diff --git a/src/tools/ipv6-subnet-calculator/index.ts b/src/tools/ipv6-subnet-calculator/index.ts new file mode 100644 index 00000000..a44352d8 --- /dev/null +++ b/src/tools/ipv6-subnet-calculator/index.ts @@ -0,0 +1,12 @@ +import { Api } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'IPv6 subnet calculator', + path: '/ipv6-subnet-calculator', + description: 'Expand, compress and dissect IPv6 addresses: network bounds, address count, netmask and address type', + keywords: ['ipv6', 'subnet', 'cidr', 'calculator', 'network', 'address', 'prefix'], + component: () => import('./ipv6-subnet-calculator.vue'), + icon: Api, + createdAt: new Date('2026-10-01'), +}); diff --git a/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.test.ts b/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.test.ts new file mode 100644 index 00000000..79a611fe --- /dev/null +++ b/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { compressIpv6, describeIpv6, expandIpv6 } from './ipv6-subnet-calculator.models'; + +describe('ipv6-subnet-calculator: expansion', () => { + it('expands compressed forms', () => { + expect(expandIpv6('2001:db8::1')).toBe('2001:0db8:0000:0000:0000:0000:0000:0001'); + expect(expandIpv6('::1')).toBe('0000:0000:0000:0000:0000:0000:0000:0001'); + expect(expandIpv6('::')).toBe('0000:0000:0000:0000:0000:0000:0000:0000'); + expect(expandIpv6('fe80::1%eth0')).toBe('fe80:0000:0000:0000:0000:0000:0000:0001'); + }); + + it('passes already-expanded addresses through', () => { + expect(expandIpv6('2001:0DB8:0:0:0:0:0:1')).toBe('2001:0db8:0000:0000:0000:0000:0000:0001'); + }); + + it('rejects malformed addresses', () => { + expect(() => expandIpv6('2001::db8::1')).toThrow('Invalid IPv6'); + expect(() => expandIpv6('12345::')).toThrow('Invalid IPv6'); + expect(() => expandIpv6('2001:db8:0:0:0:0:0')).toThrow('Invalid IPv6'); + }); +}); + +describe('ipv6-subnet-calculator: compression', () => { + it('compresses the longest zero run once', () => { + expect(compressIpv6('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe('2001:db8::1'); + expect(compressIpv6('0000:0000:0000:0000:0000:0000:0000:0001')).toBe('::1'); + // leftmost of two equal runs + // the trailing 4-zero run is the longest, so it is compressed + expect(compressIpv6('0000:0000:0001:0002:0000:0000:0000:0000')).toBe('0:0:1:2::'); + // a single zero group is not compressed + expect(compressIpv6('2001:0db8:0000:000a:000b:000c:000d:000e')).toBe('2001:db8:0:a:b:c:d:e'); + }); +}); + +describe('ipv6-subnet-calculator: subnet math', () => { + it('computes network bounds for a /48', () => { + const info = describeIpv6('2001:db8:1234:5678::1/48'); + expect(info.network).toBe('2001:db8:1234::/48'); + expect(info.firstAddress).toBe('2001:db8:1234::'); + expect(info.lastAddress).toBe('2001:db8:1234:ffff:ffff:ffff:ffff:ffff'); + expect(info.totalAddresses).toBe((2n ** 80n).toString()); + expect(info.mask).toBe('ffff:ffff:ffff::'); + }); + + it('handles the host address itself at /128', () => { + const info = describeIpv6('::1/128'); + expect(info.network).toBe('::1/128'); + expect(info.firstAddress).toBe('::1'); + expect(info.lastAddress).toBe('::1'); + expect(info.totalAddresses).toBe('1'); + }); + + it('handles the whole space at /0', () => { + const info = describeIpv6('2001:db8::1/0'); + expect(info.network).toBe('::/0'); + expect(info.firstAddress).toBe('::'); + expect(info.lastAddress).toBe('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'); + expect(info.totalAddresses).toBe((2n ** 128n).toString()); + }); + + it('classifies common address types', () => { + expect(describeIpv6('2001:db8::1/64').type).toContain('Documentation'); + expect(describeIpv6('fe80::1/64').type).toContain('Link-local'); + expect(describeIpv6('::1/128').type).toBe('Loopback'); + expect(describeIpv6('ff02::1/64').type).toContain('Multicast'); + expect(describeIpv6('fd00::1/64').type).toContain('Unique local'); + }); + + it('rejects invalid prefixes', () => { + expect(() => describeIpv6('2001:db8::1/129')).toThrow('Invalid prefix'); + expect(() => describeIpv6('2001:db8::1/-1')).toThrow('Invalid prefix'); + }); +}); diff --git a/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.ts b/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.ts new file mode 100644 index 00000000..c0385d6a --- /dev/null +++ b/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.models.ts @@ -0,0 +1,159 @@ +export interface Ipv6Info { + /** Fully expanded lowercase form, e.g. 2001:0db8:0000:...:0000. */ + expanded: string + /** RFC 5952 compressed form, e.g. 2001:db8::1. */ + compressed: string + /** `prefix` as typed, normalized. */ + network: string + prefix: number + type: string + scope: string + totalAddresses: string + firstAddress: string + lastAddress: string + mask: string +} + +const IPV6_HEX_RE = /^[0-9a-fA-F]{1,4}$/; + +/** Expand an address (without prefix) to the full 39-char lowercase form. */ +export function expandIpv6(address: string): string { + let value = address.trim().toLowerCase(); + if (value.includes('%')) { + value = value.slice(0, value.indexOf('%')); // drop zone id + } + + const parts = value.split('::'); + if (parts.length > 2) { + throw new Error(`Invalid IPv6 address: ${address}`); + } + + const head = parts[0] === '' ? [] : parts[0]!.split(':'); + const tail = parts.length === 2 ? (parts[1] === '' ? [] : parts[1]!.split(':')) : []; + const missing = 8 - head.length - tail.length; + if (parts.length === 1 && head.length !== 8) { + throw new Error(`Invalid IPv6 address: ${address}`); + } + if (missing < 0) { + throw new Error(`Invalid IPv6 address: ${address}`); + } + const groups = [...head, ...Array.from({ length: parts.length === 2 ? missing : 0 }, () => '0'), ...tail]; + if (groups.length !== 8 || !groups.every(group => IPV6_HEX_RE.test(group))) { + throw new Error(`Invalid IPv6 address: ${address}`); + } + return groups.map(group => group.padStart(4, '0')).join(':'); +} + +/** RFC 5952 compression: longest :: run, leftmost on tie, drop leading zeros. */ +export function compressIpv6(expanded: string): string { + const groups = expanded.split(':'); + const best = { start: -1, length: 0 }; + let currentStart = -1; + let currentLength = 0; + groups.forEach((group, index) => { + if (group === '0000') { + if (currentStart === -1) { + currentStart = index; + } + currentLength++; + if (currentLength > best.length) { + best.start = currentStart; + best.length = currentLength; + } + } + else { + currentStart = -1; + currentLength = 0; + } + }); + + const trimmed = groups.map(group => group.replace(/^0+(?=.)/, '')); + if (best.length < 2) { + return trimmed.join(':'); + } + const head = trimmed.slice(0, best.start).join(':'); + const tail = trimmed.slice(best.start + best.length).join(':'); + return `${head}::${tail}`; +} + +function ipv6ToBigInt(expanded: string): bigint { + return BigInt(`0x${expanded.replace(/:/g, '')}`); +} + +function bigIntToIpv6(value: bigint): string { + const hex = value.toString(16).padStart(32, '0'); + return (hex.match(/.{4}/g) ?? []).join(':'); +} + +function parseIpv6Cidr(input: string): { address: string; prefix: number } { + const trimmed = input.trim(); + const [address, prefixRaw] = trimmed.split('/'); + const prefix = prefixRaw === undefined ? 128 : Number(prefixRaw); + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 128) { + throw new Error(`Invalid prefix length: ${prefixRaw ?? ''}`); + } + return { address: expandIpv6(address ?? ''), prefix }; +} + +function describeType(expanded: string): { type: string; scope: string } { + const value = ipv6ToBigInt(expanded); + const first16 = Number(value >> 112n); + + if (value === 0n) { + return { type: 'Unspecified', scope: 'this host' }; + } + if (value === 1n) { + return { type: 'Loopback', scope: 'this host' }; + } + if (first16 === 0xFE80) { + return { type: 'Link-local unicast (fe80::/10)', scope: 'link' }; + } + if (first16 >= 0xFC00 && first16 <= 0xFDFF) { + return { type: 'Unique local (ULA, fc00::/7)', scope: 'organization' }; + } + if (first16 === 0xFFFF) { + return { type: 'Multicast', scope: 'well-known' }; + } + if (first16 >= 0xFF00 && first16 <= 0xFFFF) { + return { type: 'Multicast (ff00::/8)', scope: 'assigned' }; + } + if (first16 === 0x2002) { + return { type: '6to4 (2002::/16)', scope: 'global' }; + } + if (value >> 96n === 0x20010DB8n) { + return { type: 'Documentation (2001:db8::/32)', scope: 'global' }; + } + if (first16 >= 0x2000 && first16 <= 0x3FFF) { + return { type: 'Global unicast (2000::/3)', scope: 'global' }; + } + return { type: 'Reserved or other', scope: 'n/a' }; +} + +export function describeIpv6(cidr: string): Ipv6Info { + const { address, prefix } = parseIpv6Cidr(cidr); + const addressValue = ipv6ToBigInt(address); + + const hostBits = 128 - prefix; + const networkValue = prefix === 0 ? 0n : (addressValue >> BigInt(hostBits)) << BigInt(hostBits); + const total = hostBits >= 0 ? (1n << BigInt(hostBits)) : 0n; + const firstValue = networkValue; + const lastValue = networkValue + total - 1n; + + const maskHostBits = BigInt(hostBits); + const maskValue = hostBits === 0 ? 0n : ((1n << (128n - maskHostBits)) - 1n) << maskHostBits; + + const { type, scope } = describeType(address); + + return { + expanded: address, + compressed: compressIpv6(address), + network: `${compressIpv6(bigIntToIpv6(networkValue))}/${prefix}`, + prefix, + type, + scope, + totalAddresses: total.toString(), + firstAddress: compressIpv6(bigIntToIpv6(firstValue)), + lastAddress: compressIpv6(bigIntToIpv6(lastValue)), + mask: compressIpv6(bigIntToIpv6(maskValue)), + }; +} diff --git a/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.vue b/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.vue new file mode 100644 index 00000000..12ad9803 --- /dev/null +++ b/src/tools/ipv6-subnet-calculator/ipv6-subnet-calculator.vue @@ -0,0 +1,71 @@ + + + + + + + + + + + + + {{ row.label }}: + + {{ row.value }} + + + + + + + + +