125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
/**
|
|
* Classify a free-text search query into a VRP lookup target.
|
|
*
|
|
* The backend VRP lookup (`GET /runs/{id}/vrps/lookup`) accepts exactly one of
|
|
* `ip` / `prefix`, plus an optional `asn` filter. An ASN-only query cannot be
|
|
* served by the API — the UI surfaces that explicitly instead of guessing.
|
|
*/
|
|
|
|
export type NetworkQuery =
|
|
| { kind: "ip"; ip: string; asn?: number }
|
|
| { kind: "prefix"; prefix: string; asn?: number }
|
|
| { kind: "asn"; asn: number }
|
|
| { kind: "none" };
|
|
|
|
const ASN_MAX = 4_294_967_295;
|
|
|
|
/** "AS45090" / "as45090" / "45090" → ASN, else null. */
|
|
export function parseAsn(input: string): number | null {
|
|
const match = /^(?:as)?(\d{1,10})$/i.exec(input.trim());
|
|
if (!match) return null;
|
|
const asn = Number(match[1]);
|
|
return Number.isSafeInteger(asn) && asn <= ASN_MAX ? asn : null;
|
|
}
|
|
|
|
function isIpv4Octets(addr: string): boolean {
|
|
const parts = addr.split(".");
|
|
return (
|
|
parts.length === 4 &&
|
|
parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
);
|
|
}
|
|
|
|
function isIpv6(addr: string): boolean {
|
|
if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false;
|
|
if ((addr.match(/::/g) ?? []).length > 1) return false;
|
|
const isGroup = (g: string, isLast: boolean) =>
|
|
isLast && g.includes(".")
|
|
? isIpv4Octets(g) // embedded IPv4 tail, e.g. "::ffff:192.0.2.1"
|
|
: /^[0-9a-fA-F]{1,4}$/.test(g);
|
|
const ipv4TailWeight = (groups: string[]) =>
|
|
groups.length > 0 && groups[groups.length - 1].includes(".") ? 1 : 0;
|
|
if (addr.includes("::")) {
|
|
const [head, tail] = addr.split("::");
|
|
const headGroups = head ? head.split(":") : [];
|
|
const tailGroups = tail ? tail.split(":") : [];
|
|
// "::" compresses at least one group; an IPv4 tail counts as two groups.
|
|
const effective =
|
|
headGroups.length + tailGroups.length + ipv4TailWeight(tailGroups);
|
|
if (effective > 7) return false;
|
|
return [...headGroups, ...tailGroups].every((g, i, all) =>
|
|
isGroup(g, i === all.length - 1),
|
|
);
|
|
}
|
|
const groups = addr.split(":");
|
|
const expected = groups[groups.length - 1]?.includes(".") ? 7 : 8;
|
|
if (groups.length !== expected) return false;
|
|
return groups.every((g, i) => isGroup(g, i === groups.length - 1));
|
|
}
|
|
|
|
/** Bare IPv4/IPv6 address (no "/") → normalized string, else null. */
|
|
export function parseIpAddress(input: string): string | null {
|
|
const s = input.trim();
|
|
if (s.includes("/")) return null;
|
|
if (isIpv4Octets(s)) return s;
|
|
if (s.includes(":") && isIpv6(s)) return s;
|
|
return null;
|
|
}
|
|
|
|
function parsePrefixParts(s: string): [string, number] | null {
|
|
const slash = s.indexOf("/");
|
|
if (slash < 0 || slash !== s.lastIndexOf("/")) return null;
|
|
const addr = s.slice(0, slash);
|
|
const lenRaw = s.slice(slash + 1);
|
|
if (!/^\d{1,3}$/.test(lenRaw)) return null;
|
|
return [addr, Number(lenRaw)];
|
|
}
|
|
|
|
/** "addr/len" CIDR with family-correct length → normalized string, else null. */
|
|
export function parsePrefix(input: string): string | null {
|
|
const s = input.trim();
|
|
const parts = parsePrefixParts(s);
|
|
if (!parts) return null;
|
|
const [addr, len] = parts;
|
|
if (isIpv4Octets(addr)) return len <= 32 ? `${addr}/${len}` : null;
|
|
if (addr.includes(":") && isIpv6(addr)) return len <= 128 ? `${addr}/${len}` : null;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none.
|
|
* ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so
|
|
* no overlap. A bare number is always treated as an ASN.)
|
|
*
|
|
* Two whitespace-separated tokens are read as a combined query — one network
|
|
* token (IP or prefix) plus one ASN token (`AS13335` or `13335`), in either
|
|
* order — yielding an ip/prefix query carrying an `asn` filter.
|
|
*/
|
|
export function classifyNetworkQuery(input: string): NetworkQuery {
|
|
const s = input.trim();
|
|
if (!s) return { kind: "none" };
|
|
const tokens = s.split(/\s+/);
|
|
if (tokens.length === 2) {
|
|
for (const [netToken, asnToken] of [
|
|
[tokens[0], tokens[1]],
|
|
[tokens[1], tokens[0]],
|
|
] as const) {
|
|
const asn = parseAsn(asnToken);
|
|
if (asn === null) continue;
|
|
const prefix = parsePrefix(netToken);
|
|
if (prefix) return { kind: "prefix", prefix, asn };
|
|
const ip = parseIpAddress(netToken);
|
|
if (ip) return { kind: "ip", ip, asn };
|
|
}
|
|
return { kind: "none" };
|
|
}
|
|
if (tokens.length > 2) return { kind: "none" };
|
|
const prefix = parsePrefix(s);
|
|
if (prefix) return { kind: "prefix", prefix };
|
|
const ip = parseIpAddress(s);
|
|
if (ip) return { kind: "ip", ip };
|
|
const asn = parseAsn(s);
|
|
if (asn !== null) return { kind: "asn", asn };
|
|
return { kind: "none" };
|
|
}
|