diff --git a/backend/routes/asnLookup.js b/backend/routes/asnLookup.js new file mode 100644 index 0000000..3a671d2 --- /dev/null +++ b/backend/routes/asnLookup.js @@ -0,0 +1,249 @@ +// backend/routes/asnLookup.js +const express = require('express'); +const https = require('https'); +const pino = require('pino'); +const Sentry = require('@sentry/node'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const router = express.Router(); + +// ─── In-Memory Cache (24h TTL) ─────────────────────────────────────────────── +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const cache = new Map(); // key → { data, expiresAt } + +function getCached(key) { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key, data) { + cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); +} + +// ─── HTTP Helper ────────────────────────────────────────────────────────────── +function fetchJson(url) { + return new Promise((resolve, reject) => { + const req = https.get(url, { + headers: { + 'User-Agent': 'uTools-Network-Suite/1.0 (https://github.com/MrUnknownDE/utools)', + 'Accept': 'application/json', + }, + timeout: 8000, + }, (res) => { + let raw = ''; + res.on('data', (chunk) => { raw += chunk; }); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + return reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + } + try { + resolve(JSON.parse(raw)); + } catch (e) { + reject(new Error(`JSON parse error from ${url}: ${e.message}`)); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout fetching ${url}`)); }); + }); +} + +// ─── ASN Validation ─────────────────────────────────────────────────────────── +function parseAsn(raw) { + if (!raw || typeof raw !== 'string') return null; + // Accept "15169", "AS15169", "as15169" + const cleaned = raw.trim().toUpperCase().replace(/^AS/, ''); + const n = parseInt(cleaned, 10); + if (isNaN(n) || n < 1 || n > 4294967295 || String(n) !== cleaned) return null; + return n; +} + +// ─── RIPE Stat Fetchers ─────────────────────────────────────────────────────── +async function fetchOverview(asn) { + const cacheKey = `overview:${asn}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `https://stat.ripe.net/data/as-overview/data.json?resource=AS${asn}`; + const json = await fetchJson(url); + const d = json?.data; + const result = { + asn, + name: d?.holder || null, + announced: d?.announced ?? false, + type: d?.type || null, + block: d?.block || null, + }; + setCache(cacheKey, result); + return result; +} + +async function fetchNeighbours(asn) { + const cacheKey = `neighbours:${asn}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS${asn}`; + const json = await fetchJson(url); + const neighbours = (json?.data?.neighbours || []).map(n => ({ + asn: n.asn, + type: n.type, // 'left' = upstream, 'right' = downstream + power: n.power || 0, + v4_peers: n.v4_peers || 0, + v6_peers: n.v6_peers || 0, + })); + setCache(cacheKey, neighbours); + return neighbours; +} + +async function fetchPrefixes(asn) { + const cacheKey = `prefixes:${asn}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}`; + const json = await fetchJson(url); + const prefixes = (json?.data?.prefixes || []).map(p => p.prefix); + setCache(cacheKey, prefixes); + return prefixes; +} + +async function fetchPeeringDb(asn) { + const cacheKey = `peeringdb:${asn}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + try { + const url = `https://www.peeringdb.com/api/net?asn=${asn}&depth=2`; + const json = await fetchJson(url); + const net = json?.data?.[0]; + if (!net) { setCache(cacheKey, null); return null; } + + const result = { + peeringPolicy: net.policy_general || null, + infoType: net.info_type || null, + website: net.website || null, + ixps: (net.netixlan_set || []).map(ix => ({ + name: ix.name, + speed: ix.speed, + ipv4: ix.ipaddr4 || null, + ipv6: ix.ipaddr6 || null, + })).slice(0, 20), // max 20 IXPs + }; + setCache(cacheKey, result); + return result; + } catch (e) { + logger.warn({ asn, error: e.message }, 'PeeringDB fetch failed'); + return null; + } +} + +// ─── Route ──────────────────────────────────────────────────────────────────── +router.get('/', async (req, res, next) => { + const rawAsn = req.query.asn; + const requestIp = req.ip; + + const asn = parseAsn(String(rawAsn || '')); + if (!asn) { + return res.status(400).json({ success: false, error: 'Invalid ASN. Please provide a number between 1 and 4294967295, e.g. ?asn=15169' }); + } + + logger.info({ requestIp, asn }, 'ASN lookup request'); + + try { + // Level 1 + Level 2: overview + direct neighbours + prefixes + PeeringDB (parallel) + const [overview, neighbours, prefixes, peeringdb] = await Promise.all([ + fetchOverview(asn), + fetchNeighbours(asn), + fetchPrefixes(asn), + fetchPeeringDb(asn), + ]); + + // Split neighbours into upstream (left) and downstream (right) + const upstreams = neighbours + .filter(n => n.type === 'left') + .sort((a, b) => b.power - a.power) + .slice(0, 10); // Top 10 upstreams for Level 2 + + const downstreams = neighbours + .filter(n => n.type === 'right') + .sort((a, b) => b.power - a.power) + .slice(0, 10); // Top 10 downstreams for Level 2 + + // Level 3: fetch upstreams of upstreams (top 5 of Level 2 upstreams only) + const level3Raw = await Promise.allSettled( + upstreams.slice(0, 5).map(async (upstreamNode) => { + const theirNeighbours = await fetchNeighbours(upstreamNode.asn); + const overviewResult = await fetchOverview(upstreamNode.asn); + // Their upstreams (left) = Level 3 + const theirUpstreams = theirNeighbours + .filter(n => n.type === 'left') + .sort((a, b) => b.power - a.power) + .slice(0, 3); // Top 3 per Level-2 upstream + return { + parentAsn: upstreamNode.asn, + parentName: overviewResult.name, + theirUpstreams, + }; + }) + ); + + // Collect Level 3 nodes, resolve names for them + const level3Data = level3Raw + .filter(r => r.status === 'fulfilled') + .map(r => r.value); + + // Flatten all unique Level 3 ASNs and fetch their names + const level3Asns = [...new Set( + level3Data.flatMap(d => d.theirUpstreams.map(n => n.asn)) + )]; + const level3Names = await Promise.allSettled( + level3Asns.map(a => fetchOverview(a)) + ); + const asnNameMap = {}; + level3Names.forEach((r, i) => { + if (r.status === 'fulfilled') asnNameMap[level3Asns[i]] = r.value.name; + }); + // Also include Level 2 names + [...upstreams, ...downstreams].forEach(n => { + if (!asnNameMap[n.asn]) asnNameMap[n.asn] = null; + }); + + // Build graph structure for frontend + const graph = { + center: { asn, name: overview.name }, + level2: { + upstreams: upstreams.map(n => ({ asn: n.asn, name: asnNameMap[n.asn] || null, power: n.power, v4: n.v4_peers, v6: n.v6_peers })), + downstreams: downstreams.map(n => ({ asn: n.asn, name: asnNameMap[n.asn] || null, power: n.power, v4: n.v4_peers, v6: n.v6_peers })), + }, + level3: level3Data.map(d => ({ + parentAsn: d.parentAsn, + parentName: d.parentName, + upstreams: d.theirUpstreams.map(n => ({ asn: n.asn, name: asnNameMap[n.asn] || null, power: n.power })), + })), + }; + + res.json({ + success: true, + asn, + name: overview.name, + announced: overview.announced, + type: overview.type, + prefixes: prefixes.slice(0, 100), // max 100 prefixes + peeringdb, + graph, + }); + + } catch (error) { + logger.error({ asn, requestIp, error: error.message }, 'ASN lookup failed'); + Sentry.captureException(error, { extra: { asn, requestIp } }); + next(error); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 0ebf213..6827444 100644 --- a/backend/server.js +++ b/backend/server.js @@ -33,6 +33,7 @@ const whoisLookupRoutes = require('./routes/whoisLookup'); const versionRoutes = require('./routes/version'); const portScanRoutes = require('./routes/portScan'); const macLookupRoutes = require('./routes/macLookup'); +const asnLookupRoutes = require('./routes/asnLookup'); // --- Logger Initialisierung --- const logger = pino({ @@ -102,6 +103,7 @@ app.use('/api/whois-lookup', whoisLookupRoutes); app.use('/api/version', versionRoutes); app.use('/api/port-scan', portScanRoutes); app.use('/api/mac-lookup', macLookupRoutes); +app.use('/api/asn-lookup', asnLookupRoutes); // --- Sentry Error Handler --- diff --git a/frontend/app/asn-lookup.html b/frontend/app/asn-lookup.html new file mode 100644 index 0000000..0d7be9f --- /dev/null +++ b/frontend/app/asn-lookup.html @@ -0,0 +1,437 @@ + + + + + + + ASN / AS Lookup - uTools + + + + + + + + + +
+

uTools Network + Suite

+ +
+ +
+ +

AS / ASN Lookup

+

Peering graph, prefixes & IXP connections for any + Autonomous System

+ + +
+ + +
+ + + + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/frontend/app/asn-lookup.js b/frontend/app/asn-lookup.js new file mode 100644 index 0000000..4e4ff46 --- /dev/null +++ b/frontend/app/asn-lookup.js @@ -0,0 +1,326 @@ +// frontend/app/asn-lookup.js +'use strict'; + +const API_BASE = '/api'; + +// ─── State ──────────────────────────────────────────────────────────────────── +let currentData = null; +let showAllPrefixes = false; + +// ─── DOM Refs ───────────────────────────────────────────────────────────────── +const asnInput = document.getElementById('asn-input'); +const lookupButton = document.getElementById('lookup-button'); +const errorBox = document.getElementById('error-box'); +const loadingSection = document.getElementById('loading-section'); +const loadingMsg = document.getElementById('loading-msg'); +const resultsSection = document.getElementById('results-section'); + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function showError(msg) { + errorBox.textContent = msg; + errorBox.classList.remove('hidden'); + loadingSection.classList.add('hidden'); + resultsSection.classList.add('hidden'); +} +function hideError() { errorBox.classList.add('hidden'); } + +function setLoading(msg = 'Fetching AS data…') { + hideError(); + loadingMsg.textContent = msg; + loadingSection.classList.remove('hidden'); + resultsSection.classList.add('hidden'); +} + +function updateUrlParam(asn) { + const url = new URL(window.location); + url.searchParams.set('asn', asn); + window.history.pushState({}, '', url); +} + +// ─── Main Lookup ────────────────────────────────────────────────────────────── +async function doLookup(rawAsn) { + const asn = String(rawAsn || '').trim().toUpperCase().replace(/^AS/, ''); + if (!asn || isNaN(Number(asn))) { + showError('Please enter a valid ASN (e.g. 15169 or AS3320).'); + return; + } + + setLoading('Querying RIPE Stat & PeeringDB…'); + updateUrlParam(asn); + + try { + const res = await fetch(`${API_BASE}/asn-lookup?asn=${encodeURIComponent(asn)}`); + const data = await res.json(); + + if (!res.ok || !data.success) { + showError(data.error || `Request failed (HTTP ${res.status})`); + return; + } + + currentData = data; + renderResults(data); + + } catch (err) { + showError(`Network error: ${err.message}`); + } +} + +// ─── Render ─────────────────────────────────────────────────────────────────── +function renderResults(data) { + loadingSection.classList.add('hidden'); + resultsSection.classList.remove('hidden'); + + // Header + document.getElementById('res-asn').textContent = `AS${data.asn}`; + document.getElementById('res-name').textContent = data.name || 'Unknown'; + + const announcedBadge = document.getElementById('res-announced-badge'); + if (data.announced) announcedBadge.classList.remove('hidden'); + else announcedBadge.classList.add('hidden'); + + const typeBadge = document.getElementById('res-type-badge'); + typeBadge.textContent = data.type || ''; + typeBadge.classList.toggle('hidden', !data.type); + + const peeringPolicy = data.peeringdb?.peeringPolicy; + document.getElementById('res-policy').textContent = + peeringPolicy ? `Peering Policy: ${peeringPolicy}` : ''; + + document.getElementById('res-upstream-count').textContent = data.graph.level2.upstreams.length; + document.getElementById('res-downstream-count').textContent = data.graph.level2.downstreams.length; + document.getElementById('res-prefix-count').textContent = data.prefixes.length; + + // Graph + renderGraph(data.graph); + + // Prefixes + renderPrefixes(data.prefixes); + + // IXPs + renderIxps(data.peeringdb?.ixps); + + // Neighbour tables + renderNeighbourTable('upstream-table', data.graph.level2.upstreams, 'blue'); + renderNeighbourTable('downstream-table', data.graph.level2.downstreams, 'green'); +} + +// ─── Prefix List ───────────────────────────────────────────────────────────── +function renderPrefixes(prefixes) { + const list = document.getElementById('prefix-list'); + const empty = document.getElementById('prefix-empty'); + const toggle = document.getElementById('prefix-toggle'); + + if (!prefixes || prefixes.length === 0) { + list.classList.add('hidden'); + empty.classList.remove('hidden'); + toggle.classList.add('hidden'); + return; + } + empty.classList.add('hidden'); + toggle.classList.remove('hidden'); + + const toShow = showAllPrefixes ? prefixes : prefixes.slice(0, 20); + list.innerHTML = toShow.map(p => `${p}`).join(''); + toggle.textContent = showAllPrefixes ? 'Show less' : `Show all (${prefixes.length})`; +} + +document.getElementById('prefix-toggle').addEventListener('click', () => { + showAllPrefixes = !showAllPrefixes; + if (currentData) renderPrefixes(currentData.prefixes); +}); + +// ─── IXP List ───────────────────────────────────────────────────────────────── +function renderIxps(ixps) { + const list = document.getElementById('ixp-list'); + const empty = document.getElementById('ixp-empty'); + + if (!ixps || ixps.length === 0) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = ixps.map(ix => ` +
+ ${ix.name} + ${ix.speed >= 1000 ? `${ix.speed / 1000}G` : `${ix.speed}M`} +
+ `).join(''); +} + +// ─── Neighbour Table ────────────────────────────────────────────────────────── +function renderNeighbourTable(elId, nodes, colour) { + const el = document.getElementById(elId); + if (!nodes || nodes.length === 0) { + el.innerHTML = `

None reported.

`; + return; + } + const colClass = colour === 'blue' ? 'text-blue-400' : 'text-green-400'; + el.innerHTML = nodes.map(n => ` +
+ AS${n.asn} + ${n.name || '—'} + ${n.power ? `pwr:${n.power}` : ''} +
+ `).join(''); +} + +// ─── D3 Network Graph ───────────────────────────────────────────────────────── +function renderGraph(graph) { + const container = document.getElementById('graph-container'); + const svg = d3.select('#graph-svg'); + svg.selectAll('*').remove(); + + const W = container.clientWidth; + const H = container.clientHeight; + + // ── Build nodes & links ─────────────────────────────────────────────────── + const nodeMap = new Map(); + + function addNode(asn, name, role) { + const key = String(asn); + if (!nodeMap.has(key)) nodeMap.set(key, { id: key, asn, name: name || `AS${asn}`, role }); + } + + addNode(graph.center.asn, graph.center.name, 'center'); + graph.level2.upstreams.forEach(n => addNode(n.asn, n.name, 'upstream')); + graph.level2.downstreams.forEach(n => addNode(n.asn, n.name, 'downstream')); + graph.level3.forEach(d => { + d.upstreams.forEach(n => addNode(n.asn, n.name, 'tier1')); + }); + + const nodes = Array.from(nodeMap.values()); + + const links = []; + const centerId = String(graph.center.asn); + + graph.level2.upstreams.forEach(n => { + links.push({ source: String(n.asn), target: centerId, type: 'upstream', power: n.power || 1 }); + }); + graph.level2.downstreams.forEach(n => { + links.push({ source: centerId, target: String(n.asn), type: 'downstream', power: n.power || 1 }); + }); + graph.level3.forEach(d => { + d.upstreams.forEach(n => { + links.push({ source: String(n.asn), target: String(d.parentAsn), type: 'tier1', power: n.power || 1 }); + }); + }); + + // Remove duplicate links + const uniqueLinks = Array.from( + new Map(links.map(l => [`${l.source}-${l.target}`, l])).values() + ); + + // ── Layer X positions (fixed horizontal layout) ──────────────────────── + const layerX = { tier1: W * 0.08, upstream: W * 0.3, center: W * 0.55, downstream: W * 0.8 }; + + nodes.forEach(n => { + n.fx = layerX[n.role] ?? W / 2; + }); + + // ── Power → stroke width ────────────────────────────────────────────── + const maxPower = Math.max(...uniqueLinks.map(l => l.power), 1); + const strokeScale = d3.scaleLinear().domain([0, maxPower]).range([0.5, 4]); + + // ── Node size ───────────────────────────────────────────────────────── + const nodeRadius = { center: 20, upstream: 11, downstream: 11, tier1: 8 }; + + // ── Simulation ──────────────────────────────────────────────────────── + const sim = d3.forceSimulation(nodes) + .force('link', d3.forceLink(uniqueLinks).id(d => d.id).distance(d => { + if (d.type === 'tier1') return 90; + if (d.type === 'upstream') return 130; + return 110; + }).strength(0.6)) + .force('charge', d3.forceManyBody().strength(-220)) + .force('y', d3.forceY(H / 2).strength(0.04)) + .force('collide', d3.forceCollide().radius(d => nodeRadius[d.role] + 14)) + .alphaDecay(0.025); + + // ── Zoom/Pan ────────────────────────────────────────────────────────── + const g = svg.append('g'); + svg.call(d3.zoom().scaleExtent([0.3, 3]).on('zoom', evt => g.attr('transform', evt.transform))); + + // ── Draw links ──────────────────────────────────────────────────────── + const link = g.append('g').selectAll('line') + .data(uniqueLinks).join('line') + .attr('class', d => `link link-${d.type}`) + .attr('stroke-width', d => strokeScale(d.power)); + + // ── Draw nodes ──────────────────────────────────────────────────────── + const tooltip = document.getElementById('graph-tooltip'); + + const node = g.append('g').selectAll('g') + .data(nodes).join('g') + .attr('class', d => `node node-${d.role}`) + .style('cursor', 'pointer') + .on('click', (_, d) => { + if (d.role !== 'center') window.location.href = `/asn?asn=${d.asn}`; + }) + .on('mouseenter', (evt, d) => { + tooltip.style.opacity = '1'; + tooltip.innerHTML = ` + AS${d.asn}
+ ${d.name}
+ ${d.role === 'tier1' ? 'Tier-1 / Transit' : d.role} + `; + }) + .on('mousemove', (evt) => { + const rect = container.getBoundingClientRect(); + let x = evt.clientX - rect.left + 14; + let y = evt.clientY - rect.top - 10; + if (x + 230 > W) x = x - 244; + tooltip.style.left = x + 'px'; + tooltip.style.top = y + 'px'; + }) + .on('mouseleave', () => { tooltip.style.opacity = '0'; }) + .call(d3.drag() + .on('start', (evt, d) => { if (!evt.active) sim.alphaTarget(0.3).restart(); d.fy = d.y; }) + .on('drag', (evt, d) => { d.fy = evt.y; }) + .on('end', (evt, d) => { if (!evt.active) sim.alphaTarget(0); d.fy = null; }) + ); + + node.append('circle').attr('r', d => nodeRadius[d.role]); + + // Label: 2 lines (ASN + name truncated) + node.append('text') + .attr('dy', d => nodeRadius[d.role] + 13) + .attr('font-size', d => d.role === 'center' ? 12 : 9) + .attr('fill', '#e5e7eb') + .text(d => `AS${d.asn}`); + + node.filter(d => d.role !== 'tier1').append('text') + .attr('dy', d => nodeRadius[d.role] + 23) + .attr('font-size', 8) + .attr('fill', '#9ca3af') + .text(d => { + const max = d.role === 'center' ? 22 : 16; + return d.name && d.name.length > max ? d.name.slice(0, max) + '…' : (d.name || ''); + }); + + // ── Tick ────────────────────────────────────────────────────────────── + sim.on('tick', () => { + // Clamp Y so nodes don't fly off + nodes.forEach(n => { n.y = Math.max(30, Math.min(H - 30, n.y)); }); + + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + + node.attr('transform', d => `translate(${d.x},${d.y})`); + }); +} + +// ─── Initialise ─────────────────────────────────────────────────────────────── +lookupButton.addEventListener('click', () => doLookup(asnInput.value)); +asnInput.addEventListener('keypress', e => { if (e.key === 'Enter') doLookup(asnInput.value); }); + +// Auto-lookup from URL +const urlParam = new URLSearchParams(window.location.search).get('asn'); +if (urlParam) { + asnInput.value = urlParam; + doLookup(urlParam); +} diff --git a/frontend/app/index.html b/frontend/app/index.html index d912941..d933416 100644 --- a/frontend/app/index.html +++ b/frontend/app/index.html @@ -301,11 +301,12 @@ Suite diff --git a/frontend/app/script.js b/frontend/app/script.js index ebe6b1c..c3430b8 100644 --- a/frontend/app/script.js +++ b/frontend/app/script.js @@ -287,7 +287,14 @@ document.addEventListener('DOMContentLoaded', () => { updateField(coordsEl, data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null); updateField(timezoneEl, data.geo?.timezone, geoLoader); // Hide loader on last geo field - updateField(asnNumberEl, data.asn?.number, null, asnErrorEl); + updateField(asnNumberEl, data.asn?.number + ? `AS${data.asn.number}` + : null, null, asnErrorEl); + // Make ASN a clickable link to ASN Lookup + if (data.asn?.number && asnNumberEl) { + asnNumberEl.innerHTML = + `AS${data.asn.number}`; + } updateField(asnOrgEl, data.asn?.organization, asnLoader); updateRdns(rdnsListEl, data.rdns, rdnsLoader, rdnsErrorEl); @@ -452,7 +459,13 @@ document.addEventListener('DOMContentLoaded', () => { updateField(lookupCoordsEl, data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null); updateField(lookupTimezoneEl, data.geo?.timezone); - updateField(lookupAsnNumberEl, data.asn?.number, null, lookupAsnErrorEl); + // ASN — render as clickable link if available + if (data.asn?.number && lookupAsnNumberEl) { + lookupAsnNumberEl.innerHTML = + `AS${data.asn.number}`; + } else { + updateField(lookupAsnNumberEl, data.asn?.number, null, lookupAsnErrorEl); + } updateField(lookupAsnOrgEl, data.asn?.organization); updateRdns(lookupRdnsListEl, data.rdns, null, lookupRdnsErrorEl); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a5b8860..8383ff2 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -19,6 +19,8 @@ server { rewrite ^/mac-lookup$ /mac-lookup.html last; rewrite ^/subnet$ /subnet-calculator.html last; rewrite ^/subnet-calculator$ /subnet-calculator.html last; + rewrite ^/asn$ /asn-lookup.html last; + rewrite ^/asn-lookup$ /asn-lookup.html last; # Statische Dateien direkt ausliefern location / {