mirror of
https://github.com/MrUnknownDE/utools.git
synced 2026-04-06 00:32:04 +02:00
feat: add ASN Function
This commit is contained in:
249
backend/routes/asnLookup.js
Normal file
249
backend/routes/asnLookup.js
Normal file
@@ -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;
|
||||
@@ -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 ---
|
||||
|
||||
437
frontend/app/asn-lookup.html
Normal file
437
frontend/app/asn-lookup.html
Normal file
@@ -0,0 +1,437 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ASN / AS Lookup - uTools</title>
|
||||
<meta name="description"
|
||||
content="Look up any Autonomous System Number (ASN) to see peering connections, network graph, prefixes and IXP information.">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- D3.js v7 for network graph -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<style>
|
||||
.loader {
|
||||
border: 4px solid rgba(168, 85, 247, 0.1);
|
||||
border-left-color: #d8b4fe;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(31, 41, 55, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(to right, #c084fc, #e879f9);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #d1d5db;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: #fff;
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
nav a.active-link {
|
||||
background: rgba(168, 85, 247, 0.3);
|
||||
color: #fff;
|
||||
border-color: #a855f7;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(31, 41, 55, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Network Graph ─────────────────────────────────────── */
|
||||
#graph-container {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#graph-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#graph-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.node-center circle {
|
||||
fill: #a855f7;
|
||||
stroke: #d8b4fe;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.node-upstream circle {
|
||||
fill: #3b82f6;
|
||||
stroke: #93c5fd;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.node-downstream circle {
|
||||
fill: #10b981;
|
||||
stroke: #6ee7b7;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.node-tier1 circle {
|
||||
fill: #6b7280;
|
||||
stroke: #9ca3af;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.node text {
|
||||
fill: #e5e7eb;
|
||||
font-size: 11px;
|
||||
font-family: 'Courier New', monospace;
|
||||
pointer-events: none;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.node:hover circle {
|
||||
filter: brightness(1.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link {
|
||||
stroke: rgba(255, 255, 255, 0.12);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.link-upstream {
|
||||
stroke: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.link-tier1 {
|
||||
stroke: rgba(107, 114, 128, 0.3);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.link-downstream {
|
||||
stroke: rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
#graph-tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(168, 85, 247, 0.4);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 12px;
|
||||
color: #e5e7eb;
|
||||
max-width: 220px;
|
||||
z-index: 50;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
/* Prefix list */
|
||||
.prefix-tag {
|
||||
display: inline-block;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #c084fc;
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* IXP table */
|
||||
.ixp-row {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ixp-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="bg-gray-950 text-gray-100 font-sans p-4 md:p-8 min-h-screen bg-[url('https://tailwindcss.com/_next/static/media/hero-dark.939eb757.png')] bg-cover bg-center bg-fixed selection:bg-purple-500 selection:text-white">
|
||||
|
||||
<header class="glass-panel">
|
||||
<h1>uTools <span class="text-sm font-normal text-gray-400 opacity-75 tracking-wider uppercase ml-2">Network
|
||||
Suite</span></h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/">IP Info & Tools</a></li>
|
||||
<li><a href="/subnet">Subnetz Rechner</a></li>
|
||||
<li><a href="/dns">DNS Lookup</a></li>
|
||||
<li><a href="/whois">WHOIS Lookup</a></li>
|
||||
<li><a href="/mac">MAC Lookup</a></li>
|
||||
<li><a href="/asn" class="active-link">ASN Lookup</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="container mx-auto max-w-6xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
|
||||
<h1 class="text-3xl font-bold mb-2 text-center text-gradient">AS / ASN Lookup</h1>
|
||||
<p class="text-center text-gray-400 text-sm mb-8">Peering graph, prefixes & IXP connections for any
|
||||
Autonomous System</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6 max-w-2xl mx-auto">
|
||||
<input type="text" id="asn-input" placeholder="Enter ASN (e.g. 15169 or AS3320)"
|
||||
class="flex-grow px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all placeholder-gray-600">
|
||||
<button id="lookup-button"
|
||||
class="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold py-3 px-6 rounded-lg shadow-lg hover:shadow-purple-500/25 transition-all duration-200 ease-in-out transform hover:-translate-y-0.5">
|
||||
Lookup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="error-box"
|
||||
class="hidden max-w-2xl mx-auto mb-6 p-4 bg-red-900/30 border border-red-500/40 text-red-300 rounded-lg text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading-section" class="hidden flex flex-col items-center gap-4 py-16">
|
||||
<div class="loader" style="width:40px;height:40px;border-width:5px;"></div>
|
||||
<p class="text-gray-400 text-sm" id="loading-msg">Fetching AS data…</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results-section" class="hidden fade-in">
|
||||
|
||||
<!-- AS Info Header -->
|
||||
<div class="glass-card rounded-xl p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span id="res-asn" class="font-mono text-2xl font-bold text-purple-400"></span>
|
||||
<span id="res-announced-badge"
|
||||
class="hidden text-xs px-2 py-0.5 bg-green-500/20 border border-green-500/40 text-green-400 rounded-full">Announced</span>
|
||||
<span id="res-type-badge"
|
||||
class="text-xs px-2 py-0.5 bg-blue-500/20 border border-blue-500/40 text-blue-300 rounded-full"></span>
|
||||
</div>
|
||||
<h2 id="res-name" class="text-xl font-semibold text-white mb-1"></h2>
|
||||
<p id="res-policy" class="text-sm text-gray-400"></p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-center">
|
||||
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
|
||||
<div id="res-upstream-count" class="text-xl font-bold text-blue-400">—</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">Upstreams</div>
|
||||
</div>
|
||||
<div class="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
||||
<div id="res-downstream-count" class="text-xl font-bold text-green-400">—</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">Downstreams</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-purple-500/10 border border-purple-500/20 rounded-lg p-3 col-span-2 sm:col-span-1">
|
||||
<div id="res-prefix-count" class="text-xl font-bold text-purple-400">—</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">Prefixes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Map -->
|
||||
<div class="glass-card rounded-xl p-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<h3 class="text-lg font-bold text-purple-300">Network Map</h3>
|
||||
<div class="flex gap-3 text-xs text-gray-400">
|
||||
<span class="flex items-center gap-1"><span
|
||||
class="inline-block w-3 h-3 rounded-full bg-gray-500"></span>Tier-1 / Transit</span>
|
||||
<span class="flex items-center gap-1"><span
|
||||
class="inline-block w-3 h-3 rounded-full bg-blue-500"></span>Upstream</span>
|
||||
<span class="flex items-center gap-1"><span
|
||||
class="inline-block w-3 h-3 rounded-full bg-purple-500"></span>This AS</span>
|
||||
<span class="flex items-center gap-1"><span
|
||||
class="inline-block w-3 h-3 rounded-full bg-green-500"></span>Downstream</span>
|
||||
</div>
|
||||
<span class="ml-auto text-xs text-gray-500">Scroll to zoom · Drag to pan · Click node to open</span>
|
||||
</div>
|
||||
<div id="graph-container">
|
||||
<svg id="graph-svg"></svg>
|
||||
<div id="graph-tooltip"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prefixes + IXPs side by side -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
<!-- Prefixes -->
|
||||
<div class="glass-card rounded-xl p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest">Announced Prefixes</h3>
|
||||
<button id="prefix-toggle"
|
||||
class="text-xs text-purple-400 hover:text-purple-300 transition-colors">Show all</button>
|
||||
</div>
|
||||
<div id="prefix-list" class="max-h-48 overflow-y-auto"></div>
|
||||
<p id="prefix-empty" class="hidden text-sm text-gray-500 italic">No prefix data available.</p>
|
||||
</div>
|
||||
|
||||
<!-- IXPs -->
|
||||
<div class="glass-card rounded-xl p-5">
|
||||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest mb-3">IXP Presence <span
|
||||
class="text-xs font-normal text-gray-500">(via PeeringDB)</span></h3>
|
||||
<div id="ixp-list" class="space-y-1 text-sm max-h-48 overflow-y-auto">
|
||||
<p id="ixp-empty" class="text-gray-500 italic text-sm">Not listed on PeeringDB.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct Peers Table -->
|
||||
<div class="glass-card rounded-xl p-5">
|
||||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest mb-3">Direct Neighbours <span
|
||||
class="text-xs font-normal text-gray-500">(Level 2 · via RIPE Stat)</span></h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Upstreams -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-blue-400 mb-2 flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 17a.75.75 0 01-.75-.75V5.56l-2.47 2.47a.75.75 0 01-1.06-1.06l3.75-3.75a.75.75 0 011.06 0l3.75 3.75a.75.75 0 11-1.06 1.06L10.75 5.56v10.69A.75.75 0 0110 17z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Upstreams (Transit Providers)
|
||||
</h4>
|
||||
<div id="upstream-table" class="space-y-1 text-xs font-mono max-h-52 overflow-y-auto"></div>
|
||||
</div>
|
||||
<!-- Downstreams -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-green-400 mb-2 flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 3a.75.75 0 01.75.75v10.69l2.47-2.47a.75.75 0 111.06 1.06l-3.75 3.75a.75.75 0 01-1.06 0l-3.75-3.75a.75.75 0 111.06-1.06L9.25 14.44V3.75A.75.75 0 0110 3z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Downstreams (Customers)
|
||||
</h4>
|
||||
<div id="downstream-table" class="space-y-1 text-xs font-mono max-h-52 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /results -->
|
||||
|
||||
<footer class="mt-12 pt-6 border-t border-gray-700/30 text-center text-xs text-gray-500">
|
||||
<p>Data: <a href="https://stat.ripe.net" target="_blank"
|
||||
class="text-purple-400 hover:text-purple-300 transition-colors">RIPE Stat</a> & <a
|
||||
href="https://www.peeringdb.com" target="_blank"
|
||||
class="text-purple-400 hover:text-purple-300 transition-colors">PeeringDB</a> · Cache: 24h</p>
|
||||
<p class="mt-1">© 2025 <a href="https://mrunk.de"
|
||||
class="text-purple-400 hover:text-purple-300 transition-colors">MrUnknownDE</a></p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="asn-lookup.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
326
frontend/app/asn-lookup.js
Normal file
326
frontend/app/asn-lookup.js
Normal file
@@ -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 => `<span class="prefix-tag">${p}</span>`).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 => `
|
||||
<div class="ixp-row py-1.5 flex items-center justify-between gap-2 text-sm">
|
||||
<span class="text-gray-200 truncate">${ix.name}</span>
|
||||
<span class="text-xs text-gray-500 shrink-0">${ix.speed >= 1000 ? `${ix.speed / 1000}G` : `${ix.speed}M`}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ─── Neighbour Table ──────────────────────────────────────────────────────────
|
||||
function renderNeighbourTable(elId, nodes, colour) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!nodes || nodes.length === 0) {
|
||||
el.innerHTML = `<p class="text-gray-500 italic">None reported.</p>`;
|
||||
return;
|
||||
}
|
||||
const colClass = colour === 'blue' ? 'text-blue-400' : 'text-green-400';
|
||||
el.innerHTML = nodes.map(n => `
|
||||
<div class="flex items-center gap-2 py-0.5 hover:bg-white/5 rounded px-1 cursor-pointer group"
|
||||
onclick="window.location.href='/asn?asn=${n.asn}'">
|
||||
<span class="${colClass} font-bold w-14 shrink-0">AS${n.asn}</span>
|
||||
<span class="text-gray-300 truncate flex-1 group-hover:text-white">${n.name || '—'}</span>
|
||||
<span class="text-gray-600 shrink-0">${n.power ? `pwr:${n.power}` : ''}</span>
|
||||
</div>
|
||||
`).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 = `
|
||||
<strong class="text-purple-300">AS${d.asn}</strong><br>
|
||||
<span class="text-gray-300">${d.name}</span><br>
|
||||
<span class="text-gray-500 text-xs capitalize">${d.role === 'tier1' ? 'Tier-1 / Transit' : d.role}</span>
|
||||
`;
|
||||
})
|
||||
.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);
|
||||
}
|
||||
@@ -301,11 +301,12 @@
|
||||
Suite</span></h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/" class="active-link">IP Info & Tools</a></li>
|
||||
<li><a href="/" class="active-link">IP Info & Tools</a></li>
|
||||
<li><a href="/subnet">Subnetz Rechner</a></li>
|
||||
<li><a href="/dns">DNS Lookup</a></li>
|
||||
<li><a href="/whois">WHOIS Lookup</a></li>
|
||||
<li><a href="/mac">MAC Lookup</a></li>
|
||||
<li><a href="/asn">ASN Lookup</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -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 =
|
||||
`<a href="/asn?asn=${data.asn.number}" class="hover:text-purple-200 underline decoration-dotted transition-colors" title="Open ASN Lookup">AS${data.asn.number}</a>`;
|
||||
}
|
||||
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 =
|
||||
`<a href="/asn?asn=${data.asn.number}" class="text-purple-400 hover:text-purple-300 underline decoration-dotted transition-colors font-mono" title="Open ASN Lookup">AS${data.asn.number}</a>`;
|
||||
} else {
|
||||
updateField(lookupAsnNumberEl, data.asn?.number, null, lookupAsnErrorEl);
|
||||
}
|
||||
updateField(lookupAsnOrgEl, data.asn?.organization);
|
||||
|
||||
updateRdns(lookupRdnsListEl, data.rdns, null, lookupRdnsErrorEl);
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
Reference in New Issue
Block a user