/** * The device Overview is scoped to, as URL state beside the period. * * The API takes one exact address (no list) and matches the text the logger * stored, so that is what the route lets through: a value that is not an IPv4 * or IPv6 address is dropped from the URL rather than sent on to a 400, and an * IPv6 address is reduced to the RFC 5952 spelling the logger writes, so an * uppercase or expanded form in a pasted link still finds its client. The * server writes hex groups only, so an IPv4 tail is folded into the two hextets * it names and an IPv4-mapped address is reduced to its plain IPv4. An absent * scope is the whole household. */ const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; const HEXTET = /^[0-9a-f]{1,4}$/i; /** The two hextets an IPv4 tail names: `192.0.2.30` is `c000:21e`. */ function ipv4Hextets(dotted: string): string[] { const octets = dotted.split(".").map(Number); return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)]; } /** * One side of a `::` as hextets, or null. An IPv4 tail is folded into the two * hextets it names — the server prints an IPv6 address as hex groups only, * never with a dotted tail — and it is read only where the address ends, since * the tail is the last 32 bits and nothing may follow it. */ function sideHextets(parts: string[], dottedAllowed: boolean): string[] | null { const last = parts[parts.length - 1]; const hextets = last !== undefined && last.includes(".") ? dottedAllowed && IPV4.test(last) ? [...parts.slice(0, -1), ...ipv4Hextets(last)] : null : parts; if (hextets === null) return null; return hextets.every((group) => HEXTET.test(group)) ? hextets : null; } /** * RFC 4291 text — up to eight hextets, one `::` at most, an IPv4 tail allowed * as the last 32 bits — parsed to its eight hextets, or null. */ function ipv6Groups(value: string): string[] | null { const halves = value.split("::"); if (halves.length > 2) return null; const split = halves.length === 2; const head = sideHextets(halves[0] === "" ? [] : (halves[0] as string).split(":"), !split); const tail = sideHextets(split && halves[1] !== "" ? (halves[1] as string).split(":") : [], split); if (head === null || tail === null) return null; const width = head.length + tail.length; if (split ? width >= 8 : width !== 8) return null; const zeros = Array.from({ length: 8 - width }, () => "0"); const expanded = split ? [...head, ...zeros, ...tail] : head; return expanded.map((group) => group.replace(/^0+(?=.)/, "").toLowerCase()); } /** RFC 5952: lowercase, no leading zeros, the longest run of two or more zero groups as `::` (the first on a tie). */ function compressIpv6(groups: string[]): string { let best = { start: -1, length: 0 }; for (let i = 0; i < groups.length;) { if (groups[i] !== "0") { i += 1; continue; } let j = i; while (j < groups.length && groups[j] === "0") j += 1; if (j - i >= 2 && j - i > best.length) best = { start: i, length: j - i }; i = j; } if (best.start < 0) return groups.join(":"); const head = groups.slice(0, best.start).join(":"); const tail = groups.slice(best.start + best.length).join(":"); return `${head}::${tail}`; } /** * The dotted IPv4 an IPv4-mapped address stands for, or null. The server * normalizes `::ffff:a.b.c.d` to the plain `a.b.c.d`, so a link that spells the * mapped form has to be reduced the same way to find its client. */ function mappedIpv4(groups: string[]): string | null { if (groups.slice(0, 5).some((group) => group !== "0") || groups[5] !== "ffff") return null; const high = Number.parseInt(groups[6] as string, 16); const low = Number.parseInt(groups[7] as string, 16); return [high >> 8, high & 0xff, low >> 8, low & 0xff].join("."); } export function parseClient(value: unknown): string | undefined { if (typeof value !== "string") return undefined; if (IPV4.test(value)) return value; const groups = ipv6Groups(value); if (groups === null) return undefined; return mappedIpv4(groups) ?? compressIpv6(groups); }