fetchBgpHeNet moved as-is, not removed, despite being confirmed dead code in practice (its only call site in /api/lookup is hardcoded to Promise.resolve(null)) -- that's a "disabled pending re-enable" state, a bigger behavioral decision than this extraction pass should make alone. Also widened the smoke-test harness's volatile-path exclusions: the live Cloudflare RPKI feed's ASPA object count ticks between runs (independent of any code change here), and the field appears under three different names (aspa_map.entries, aspa_adoption.total_objects, plus the derived delta_from_previous) that weren't all covered by the existing aspa_objects exclusion. Re-captured baseline accordingly. Verified via smoke-test harness (28/28 match). server.js: 4827 -> 4575 lines.
147 lines
6.9 KiB
JavaScript
147 lines
6.9 KiB
JavaScript
const { fetchJSON } = require("./services/http-helpers");
|
|
const { whoisCacheGet, whoisCacheSet, WHOIS_NOT_FOUND_CACHE_TTL } = require("./caches/response-caches");
|
|
|
|
// Feature 27: WHOIS via RIPE DB
|
|
async function fetchWhois(resource) {
|
|
const result = { resource, type: null, data: null, error: null };
|
|
try {
|
|
const trimmed = resource.trim();
|
|
if (/^(AS)?\d+$/i.test(trimmed)) {
|
|
result.type = "aut-num";
|
|
const asn = trimmed.replace(/^AS/i, "");
|
|
|
|
// Check cache first
|
|
const cached = whoisCacheGet(asn);
|
|
if (cached !== undefined) {
|
|
result.data = cached;
|
|
if (!cached) result.error = "Not found in any RIR database (cached)";
|
|
return result;
|
|
}
|
|
|
|
// Try RIPE first
|
|
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
|
|
if (ripeData && ripeData.objects && ripeData.objects.object) {
|
|
const obj = ripeData.objects.object[0];
|
|
const attrs = obj.attributes?.attribute || [];
|
|
const parsed = {};
|
|
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
|
|
result.data = {
|
|
aut_num: (parsed["aut-num"] || [])[0] || "",
|
|
as_name: (parsed["as-name"] || [])[0] || "",
|
|
descr: parsed["descr"] || [],
|
|
org: (parsed["org"] || [])[0] || "",
|
|
admin_c: parsed["admin-c"] || [],
|
|
tech_c: parsed["tech-c"] || [],
|
|
mnt_by: parsed["mnt-by"] || [],
|
|
status: (parsed["status"] || [])[0] || "",
|
|
created: (parsed["created"] || [])[0] || "",
|
|
last_modified: (parsed["last-modified"] || [])[0] || "",
|
|
source: (parsed["source"] || [])[0] || "",
|
|
import: parsed["import"] || [],
|
|
export: parsed["export"] || [],
|
|
remarks: parsed["remarks"] || [],
|
|
};
|
|
whoisCacheSet(asn, result.data);
|
|
}
|
|
|
|
// If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
|
|
if (!result.data) {
|
|
const rdapEndpoints = [
|
|
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
|
|
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
|
|
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
|
|
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
|
|
];
|
|
const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
|
|
fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
|
|
if (!d || d.errorCode || !d.handle) return null;
|
|
return { source: ep.name, data: d };
|
|
}).catch(() => null)
|
|
));
|
|
const found = rdapResults.find((r) => r !== null);
|
|
if (found) {
|
|
const d = found.data;
|
|
const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
|
|
const entities = d.entities || [];
|
|
const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
|
|
const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
|
|
const events = d.events || [];
|
|
const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
|
|
const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
|
|
result.data = {
|
|
aut_num: "AS" + asn,
|
|
as_name: d.name || "",
|
|
descr: remarks,
|
|
org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
|
|
admin_c: adminContacts,
|
|
tech_c: techContacts,
|
|
mnt_by: [],
|
|
status: (d.status || []).join(", "),
|
|
created: created,
|
|
last_modified: lastMod,
|
|
source: found.source + " (RDAP)",
|
|
import: [],
|
|
export: [],
|
|
remarks: remarks,
|
|
};
|
|
whoisCacheSet(asn, result.data);
|
|
} else {
|
|
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
|
|
// Short TTL: this "not found" likely reflects a transient failure across
|
|
// all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
|
|
whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
|
|
}
|
|
}
|
|
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
|
|
result.type = "inetnum";
|
|
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
|
|
if (ripeData && ripeData.objects && ripeData.objects.object) {
|
|
const results = ripeData.objects.object.map((obj) => {
|
|
const attrs = obj.attributes?.attribute || [];
|
|
const parsed = {};
|
|
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
|
|
return {
|
|
inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
|
|
netname: (parsed["netname"] || [])[0] || "",
|
|
descr: parsed["descr"] || [],
|
|
country: (parsed["country"] || [])[0] || "",
|
|
org: (parsed["org"] || [])[0] || "",
|
|
admin_c: parsed["admin-c"] || [],
|
|
tech_c: parsed["tech-c"] || [],
|
|
mnt_by: parsed["mnt-by"] || [],
|
|
status: (parsed["status"] || [])[0] || "",
|
|
created: (parsed["created"] || [])[0] || "",
|
|
last_modified: (parsed["last-modified"] || [])[0] || "",
|
|
source: (parsed["source"] || [])[0] || "",
|
|
};
|
|
});
|
|
result.data = results.length === 1 ? results[0] : results;
|
|
} else { result.error = "Not found in RIPE DB"; }
|
|
} else {
|
|
result.type = "domain";
|
|
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
|
|
if (ripeData && ripeData.objects && ripeData.objects.object) {
|
|
const obj = ripeData.objects.object[0];
|
|
const attrs = obj.attributes?.attribute || [];
|
|
const parsed = {};
|
|
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
|
|
result.data = {
|
|
domain: (parsed["domain"] || [])[0] || "",
|
|
descr: parsed["descr"] || [],
|
|
admin_c: parsed["admin-c"] || [],
|
|
tech_c: parsed["tech-c"] || [],
|
|
zone_c: parsed["zone-c"] || [],
|
|
nserver: parsed["nserver"] || [],
|
|
mnt_by: parsed["mnt-by"] || [],
|
|
created: (parsed["created"] || [])[0] || "",
|
|
last_modified: (parsed["last-modified"] || [])[0] || "",
|
|
source: (parsed["source"] || [])[0] || "",
|
|
};
|
|
} else { result.error = "Not found in RIPE DB"; }
|
|
}
|
|
} catch (err) { result.error = err.message; }
|
|
return result;
|
|
}
|
|
|
|
module.exports = { fetchWhois };
|