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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Announced
+
+
+
+
+
+
+
+
+
+
+
+
+
Network Map
+
+ Tier-1 / Transit
+ Upstream
+ This AS
+ Downstream
+
+
Scroll to zoom · Drag to pan · Click node to open
+
+
+
+
+
+
+
+
+
+
+
Announced Prefixes
+
+
+
+
No prefix data available.
+
+
+
+
+
IXP Presence (via PeeringDB)
+
+
Not listed on PeeringDB.
+
+
+
+
+
+
+
Direct Neighbours (Level 2 · via RIPE Stat)
+
+
+
+
+
+ Upstreams (Transit Providers)
+
+
+
+
+
+
+
+ Downstreams (Customers)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 / {