mirror of
https://github.com/MrUnknownDE/utools.git
synced 2026-04-19 14:13:44 +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 versionRoutes = require('./routes/version');
|
||||||
const portScanRoutes = require('./routes/portScan');
|
const portScanRoutes = require('./routes/portScan');
|
||||||
const macLookupRoutes = require('./routes/macLookup');
|
const macLookupRoutes = require('./routes/macLookup');
|
||||||
|
const asnLookupRoutes = require('./routes/asnLookup');
|
||||||
|
|
||||||
// --- Logger Initialisierung ---
|
// --- Logger Initialisierung ---
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
@@ -102,6 +103,7 @@ app.use('/api/whois-lookup', whoisLookupRoutes);
|
|||||||
app.use('/api/version', versionRoutes);
|
app.use('/api/version', versionRoutes);
|
||||||
app.use('/api/port-scan', portScanRoutes);
|
app.use('/api/port-scan', portScanRoutes);
|
||||||
app.use('/api/mac-lookup', macLookupRoutes);
|
app.use('/api/mac-lookup', macLookupRoutes);
|
||||||
|
app.use('/api/asn-lookup', asnLookupRoutes);
|
||||||
|
|
||||||
|
|
||||||
// --- Sentry Error Handler ---
|
// --- 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>
|
Suite</span></h1>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<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="/subnet">Subnetz Rechner</a></li>
|
||||||
<li><a href="/dns">DNS Lookup</a></li>
|
<li><a href="/dns">DNS Lookup</a></li>
|
||||||
<li><a href="/whois">WHOIS Lookup</a></li>
|
<li><a href="/whois">WHOIS Lookup</a></li>
|
||||||
<li><a href="/mac">MAC Lookup</a></li>
|
<li><a href="/mac">MAC Lookup</a></li>
|
||||||
|
<li><a href="/asn">ASN Lookup</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -287,7 +287,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateField(coordsEl, data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null);
|
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(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);
|
updateField(asnOrgEl, data.asn?.organization, asnLoader);
|
||||||
|
|
||||||
updateRdns(rdnsListEl, data.rdns, rdnsLoader, rdnsErrorEl);
|
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(lookupCoordsEl, data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null);
|
||||||
updateField(lookupTimezoneEl, data.geo?.timezone);
|
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);
|
updateField(lookupAsnOrgEl, data.asn?.organization);
|
||||||
|
|
||||||
updateRdns(lookupRdnsListEl, data.rdns, null, lookupRdnsErrorEl);
|
updateRdns(lookupRdnsListEl, data.rdns, null, lookupRdnsErrorEl);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ server {
|
|||||||
rewrite ^/mac-lookup$ /mac-lookup.html last;
|
rewrite ^/mac-lookup$ /mac-lookup.html last;
|
||||||
rewrite ^/subnet$ /subnet-calculator.html last;
|
rewrite ^/subnet$ /subnet-calculator.html last;
|
||||||
rewrite ^/subnet-calculator$ /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
|
# Statische Dateien direkt ausliefern
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
Reference in New Issue
Block a user