PeerCortex/src/aspa/generator.ts
Rene Fichtmueller 5c1cb1652b fix: correct ASPA RFC citation and broken submission instructions
Reapplied against the current main (post src/features/ refactor),
since the old aspa/ module was carried over unchanged and still had
both bugs. Also caught two spots the stale local clone never had:
public/index.html's title= attribute and the newer index-classic.html.

ASPA has no RFC yet, still draft-ietf-sidrops-aspa-profile (object
format) and draft-ietf-sidrops-aspa-verification (path validation),
both in WG Last Call. "RFC 9582" is the ROA profile RFC, unrelated.

The generated "submit via RIPE DB webupdates / auto-dbm@ripe.net"
instructions were wrong too. ASPA objects are signed RPKI objects
created through a RIR's hosted RPKI platform, not RPSL text pasted
into the whois database. Replaced generateASPAObject/
generateRipeDbTemplate with summarizeASPAObject (review summary) and
generateASPASubmissionGuide (per-RIR: status, where to actually
create the object, source link). aspa_generate now takes an optional
rir param instead of a RIPE-specific maintainer handle.

Also corrected an overprecise APNIC launch date: APNIC's own blog
doesn't give one, only a vague "since Nov 2025" for all three live
RIRs collectively, which doesn't match RIPE's and ARIN's own more
precise announcements.
2026-07-15 23:53:00 +02:00

309 lines
12 KiB
TypeScript

/**
* @module aspa/generator
* Detect upstream providers from BGP data and produce an ASPA submission guide.
*
* Analyzes BGP path data to detect upstream provider relationships, then
* produces a plain-language provider list plus per-RIR instructions for
* creating the actual ASPA object. Real ASPA objects are signed RPKI/CMS
* objects created through your RIR's hosted RPKI platform (or a delegated
* RPKI CA); they are not RPSL text and cannot be submitted through the
* RIPE Database (whois) the way an aut-num or route object can. There is
* no `aspa:` or `upstream:` attribute in the RIPE DB schema.
*
* @see https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-profile/ (object format, still an IETF draft, not yet an RFC)
*
* @example
* ```typescript
* const providers = detectProviders(64501, bgpPaths);
* console.log(providers);
* // [{ asn: 174, name: "Cogent", confidence: 0.95, pathCount: 42 }]
*
* const guide = generateASPASubmissionGuide(64501, providers);
* console.log(guide);
* // Provider list + a link to the RIR's actual RPKI dashboard
* ```
*/
// ── Types ───────────────────────────────────────────────
/** A BGP path observation used for provider inference */
export interface BGPPath {
/** The AS path from collector perspective (leftmost = collector peer) */
readonly asPath: ReadonlyArray<number>;
/** IP prefix associated with this path */
readonly prefix: string;
/** The collector or vantage point that observed this path */
readonly collector: string;
/** When this path was observed */
readonly timestamp: string;
}
/** A detected upstream provider with confidence scoring */
export interface Provider {
/** The provider ASN */
readonly asn: number;
/** Human-readable name (if available) */
readonly name: string;
/** Confidence that this is a true provider (0.0 to 1.0) */
readonly confidence: number;
/** Number of BGP paths supporting this inference */
readonly pathCount: number;
/** Address families observed */
readonly afi: ReadonlyArray<"ipv4" | "ipv6">;
}
// ── Provider Detection ──────────────────────────────────
/**
* Detect upstream providers for an ASN by analyzing BGP path data.
*
* Uses the valley-free routing model: in a typical BGP path, a customer AS
* appears to the right of its provider. By counting how often each AS appears
* immediately to the left of the target ASN across many paths, we can infer
* provider relationships with high confidence.
*
* Heuristics applied:
* 1. An AS appearing left of the target in many paths is likely a provider.
* 2. Higher path counts yield higher confidence.
* 3. ASNs that only appear in a single path are treated as low-confidence.
*
* @param asn - The ASN to detect providers for
* @param bgpPaths - Collection of observed BGP paths
* @returns Sorted array of detected providers (highest confidence first)
*
* @example
* ```typescript
* const paths: BGPPath[] = [
* { asPath: [3356, 174, 64501], prefix: "192.0.2.0/24", collector: "rrc00", timestamp: "2026-03-26T00:00:00Z" },
* { asPath: [6939, 174, 64501], prefix: "192.0.2.0/24", collector: "rrc01", timestamp: "2026-03-26T00:00:00Z" },
* { asPath: [13335, 64501], prefix: "192.0.2.0/24", collector: "rrc03", timestamp: "2026-03-26T00:00:00Z" },
* ];
*
* const providers = detectProviders(64501, paths);
* // [
* // { asn: 174, name: "Unknown", confidence: 0.9, pathCount: 2, afi: ["ipv4"] },
* // { asn: 13335, name: "Unknown", confidence: 0.7, pathCount: 1, afi: ["ipv4"] },
* // ]
* ```
*/
export function detectProviders(
asn: number,
bgpPaths: ReadonlyArray<BGPPath>
): ReadonlyArray<Provider> {
// Count occurrences of each ASN appearing immediately left of the target
const providerCounts = new Map<number, { count: number; afiSet: Set<string> }>();
for (const path of bgpPaths) {
const { asPath, prefix } = path;
const afi = prefix.includes(":") ? "ipv6" : "ipv4";
for (let i = 1; i < asPath.length; i++) {
if (asPath[i] === asn && asPath[i - 1] !== asn) {
const providerAsn = asPath[i - 1];
const existing = providerCounts.get(providerAsn);
if (existing) {
existing.count++;
existing.afiSet.add(afi);
} else {
providerCounts.set(providerAsn, {
count: 1,
afiSet: new Set([afi]),
});
}
}
}
}
if (providerCounts.size === 0) {
return [];
}
// Calculate confidence based on path count relative to max
const maxCount = Math.max(...Array.from(providerCounts.values()).map((v) => v.count));
const providers: Provider[] = [];
for (const [providerAsn, data] of providerCounts) {
// Confidence formula: normalized count with a floor of 0.3 for single observations
const rawConfidence = data.count / maxCount;
const confidence = Math.max(0.3, Math.min(1.0, rawConfidence * 0.9 + 0.1));
providers.push({
asn: providerAsn,
name: "Unknown", // Name resolution requires external lookup
confidence: Math.round(confidence * 100) / 100,
pathCount: data.count,
afi: Array.from(data.afiSet).sort() as Array<"ipv4" | "ipv6">,
});
}
// Sort by confidence descending, then by path count descending
return providers.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
return b.pathCount - a.pathCount;
});
}
// ── ASPA Provider Summary ────────────────────────────────
/**
* Summarize detected providers in plain language, in the ASPA object's
* logical field order (customerASID, then providers ascending by ASN,
* per draft-ietf-sidrops-aspa-profile).
*
* This is a human-readable summary for review, not a submittable object.
* There is no text format you can paste into a registry to create an
* ASPA object; it must be created through your RIR's RPKI platform.
*
* @param asn - The customer ASN
* @param providers - Detected upstream providers
* @returns Plain-language summary of the ASPA object's intended content
*
* @example
* ```typescript
* const text = summarizeASPAObject(64501, [
* { asn: 174, name: "Cogent", confidence: 0.95, pathCount: 42, afi: ["ipv4", "ipv6"] },
* ]);
* console.log(text);
* // Customer AS64501 would authorize: AS174 (Cogent, confidence 95%, seen in 42 paths)
* ```
*/
export function summarizeASPAObject(
asn: number,
providers: ReadonlyArray<Provider>
): string {
const lines: string[] = [
`# ASPA summary for AS${asn}`,
`# Generated by PeerCortex on ${new Date().toISOString()}`,
`# Based on BGP path analysis. Review before creating the real object.`,
`#`,
`# ASPA (Autonomous System Provider Authorization) declares which ASNs`,
`# are authorized upstream providers of this AS. This helps prevent`,
`# route leaks by allowing RPKI validators to verify AS path legitimacy.`,
`#`,
`# Reference: IETF draft-ietf-sidrops-aspa-profile (not yet an RFC).`,
`# This is a summary for review only. It is NOT a submittable object.`,
`# ASPA objects are signed RPKI objects created through your RIR's`,
`# hosted RPKI platform, not RPSL text you paste into a registry.`,
``,
`Customer: AS${asn}`,
];
const sortedProviders = [...providers].sort((a, b) => a.asn - b.asn);
for (const provider of sortedProviders) {
const afiStr = provider.afi.join(", ");
const comment = `${provider.name} (confidence: ${Math.round(provider.confidence * 100)}%, seen in ${provider.pathCount} paths, ${afiStr})`;
lines.push(` Provider: AS${provider.asn} # ${comment}`);
}
lines.push(``);
return lines.join("\n");
}
/** Which Regional Internet Registry an ASN belongs to, for RIR-specific guidance */
export type RIR = "ripe" | "arin" | "apnic" | "lacnic" | "afrinic";
/** Per-RIR ASPA production status and the correct place to create the object */
const RIR_ASPA_INFO: Record<
RIR,
{ readonly status: string; readonly howTo: string; readonly infoUrl: string }
> = {
ripe: {
status: "Production since 15 Dec 2025",
howTo: "RIPE NCC LIR Portal, RPKI Dashboard, ASPA section",
infoUrl: "https://www.ripe.net/manage-ips-and-asns/resource-management/rpki/aspa/",
},
arin: {
status: "Production since 20 Jan 2026",
howTo: "ARIN Online, Routing Security menu, ASPA section",
infoUrl: "https://www.arin.net/resources/manage/rpki/aspa/",
},
apnic: {
status: "Production (exact launch date not published by APNIC; blog post from 9 Jul 2026 confirms support is live)",
howTo: "MyAPNIC or the APNIC Registry API",
infoUrl: "https://help.apnic.net/s/article/ASPA",
},
lacnic: {
status: "Not yet in production. Committed for end of 2026",
howTo: "Not yet available",
infoUrl: "https://blog.lacnic.net/en/programa-rpki-nro-resumen/",
},
afrinic: {
status: "Not yet in production, no committed date",
howTo: "Not yet available",
infoUrl: "https://blog.afrinic.net/nro-rpki-program-2025-in-review",
},
};
/**
* Produce a submission guide for an ASN's detected providers.
*
* This does NOT produce a pasteable object, because ASPA objects are
* signed RPKI objects, not RPSL text. It produces the provider list to
* review plus the correct place to actually create the object for the
* given RIR.
*
* @param asn - The customer ASN
* @param providers - Detected upstream providers
* @param rir - Which RIR this ASN is registered with
* @returns Human-readable guide: provider list plus RIR-specific instructions
*
* @example
* ```typescript
* const guide = generateASPASubmissionGuide(
* 13335,
* [{ asn: 174, name: "Cogent", confidence: 0.95, pathCount: 100, afi: ["ipv4", "ipv6"] }],
* "ripe"
* );
* ```
*/
export function generateASPASubmissionGuide(
asn: number,
providers: ReadonlyArray<Provider>,
rir: RIR
): string {
const info = RIR_ASPA_INFO[rir];
const lines: string[] = [
`# ============================================================`,
`# ASPA Submission Guide for AS${asn}`,
`# Generated by PeerCortex, ${new Date().toISOString()}`,
`# ============================================================`,
`#`,
`# ASPA support at your RIR (${rir.toUpperCase()}): ${info.status}`,
`# Where to create the object: ${info.howTo}`,
`# More info: ${info.infoUrl}`,
`#`,
`# ASPA objects are signed RPKI objects. There is no text format`,
`# to paste into a registry database. Use the RIR platform above`,
`# and enter the provider ASNs listed below.`,
`#`,
``,
`Customer: AS${asn}`,
];
const highConfidence = providers.filter((p) => p.confidence >= 0.5);
const sortedHigh = [...highConfidence].sort((a, b) => a.asn - b.asn);
for (const provider of sortedHigh) {
const afiComment =
provider.afi.length === 2 ? "" : ` # ${provider.afi[0]} only`;
lines.push(` Provider to add: AS${provider.asn}${afiComment}`);
}
lines.push(``);
const lowConfidence = providers.filter((p) => p.confidence < 0.5);
if (lowConfidence.length > 0) {
lines.push(`# The following providers were detected with low confidence.`);
lines.push(`# Review before adding them:`);
const sortedLow = [...lowConfidence].sort((a, b) => a.asn - b.asn);
for (const provider of sortedLow) {
lines.push(
`# AS${provider.asn} (${provider.name}, confidence: ${Math.round(provider.confidence * 100)}%)`
);
}
lines.push(``);
}
return lines.join("\n");
}