check / check (push) Canceled after 0s
A new package, internal/blocklist, downloads seven public lists on a schedule: DShield, Feodo Tracker, FireHOL abusers and level 3, Spamhaus DROP for IPv4 and IPv6, and the Tor exit list. It keeps the last good copy of each in memory and on disk. A download sends back the Last-Modified and ETag the publisher gave for the copy, also kept on disk. Every IP lookup result carries blocklists. An address with no live route now gets a result too, without route fields, so the lookup no longer answers 404. A successful answer carries Cache-Control, private for the caller's own address. The address sets come from go4.org/netipx. Model: opus-5-5
637 lines
22 KiB
HTML
637 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RouteWatch - BGP Route Monitor</title>
|
|
<style nonce="{{.CSPNonce}}">
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
|
|
/* Navbar styles */
|
|
.navbar {
|
|
background: #2c3e50;
|
|
padding: 0 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
height: 56px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.navbar-brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
.navbar-brand a {
|
|
color: white;
|
|
text-decoration: none;
|
|
}
|
|
.navbar-brand a:hover {
|
|
color: #ecf0f1;
|
|
}
|
|
.navbar-brand .by {
|
|
font-weight: normal;
|
|
color: #95a5a6;
|
|
font-size: 14px;
|
|
}
|
|
.navbar-brand .author {
|
|
color: #3498db;
|
|
font-weight: normal;
|
|
}
|
|
.navbar-brand .author:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.navbar-links {
|
|
display: flex;
|
|
gap: 20px;
|
|
}
|
|
.navbar-links a {
|
|
color: #ecf0f1;
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.navbar-links a:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.navbar-links a.active {
|
|
background: rgba(255,255,255,0.15);
|
|
}
|
|
|
|
/* Main content */
|
|
.main-content {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 30px 20px;
|
|
}
|
|
|
|
/* Stats overview */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 40px;
|
|
}
|
|
.stat-card {
|
|
background: white;
|
|
padding: 24px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
}
|
|
.stat-value {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #2c3e50;
|
|
margin-bottom: 8px;
|
|
}
|
|
.stat-label {
|
|
font-size: 14px;
|
|
color: #7f8c8d;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.stat-card.connected .stat-value {
|
|
color: #27ae60;
|
|
}
|
|
.stat-card.disconnected .stat-value,
|
|
.stale {
|
|
color: #e74c3c;
|
|
}
|
|
/* The card has no room beside "Connected", so the spinner and "stale" go under it */
|
|
#freshness {
|
|
display: block;
|
|
font-size: 20px;
|
|
}
|
|
|
|
/* Search section */
|
|
.search-section {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
gap: 24px;
|
|
margin-bottom: 40px;
|
|
}
|
|
.search-card {
|
|
background: white;
|
|
padding: 24px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.search-card h2 {
|
|
margin: 0 0 16px 0;
|
|
font-size: 18px;
|
|
color: #2c3e50;
|
|
}
|
|
.search-input-group {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.search-input-group input {
|
|
flex: 1;
|
|
padding: 12px 16px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.search-input-group input:focus {
|
|
border-color: #3498db;
|
|
}
|
|
.search-input-group button {
|
|
padding: 12px 24px;
|
|
background: #3498db;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.search-input-group button:hover {
|
|
background: #2980b9;
|
|
}
|
|
.search-input-group button:disabled {
|
|
background: #bdc3c7;
|
|
cursor: not-allowed;
|
|
}
|
|
.search-hint {
|
|
font-size: 12px;
|
|
color: #95a5a6;
|
|
margin-top: 8px;
|
|
}
|
|
#asname-results p {
|
|
color: #7f8c8d;
|
|
margin-top: 12px;
|
|
}
|
|
#asname-results .search-message {
|
|
font-size: 13px;
|
|
}
|
|
#asname-results .error {
|
|
color: #c00;
|
|
}
|
|
.asname-list {
|
|
list-style: none;
|
|
margin: 12px 0 0 0;
|
|
padding: 0;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
.asname-list li {
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.asname-list a {
|
|
display: block;
|
|
padding: 8px 4px;
|
|
color: #2c3e50;
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
}
|
|
.asname-list a:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
.asname-list .asn {
|
|
font-weight: 600;
|
|
color: #3498db;
|
|
}
|
|
.asname-list .description {
|
|
display: block;
|
|
color: #7f8c8d;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* IP Lookup result */
|
|
.ip-result {
|
|
margin-top: 16px;
|
|
display: none;
|
|
}
|
|
.ip-result.visible {
|
|
display: block;
|
|
}
|
|
.ip-result-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
.ip-result-header h3 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
color: #2c3e50;
|
|
}
|
|
.ip-result-header button {
|
|
background: none;
|
|
border: none;
|
|
color: #e74c3c;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
.ip-result pre,
|
|
#ip-api pre {
|
|
background: #2c3e50;
|
|
color: #ecf0f1;
|
|
padding: 16px;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
.ip-result .error,
|
|
#ip-api .error {
|
|
background: #fee;
|
|
color: #c00;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
}
|
|
.ip-result .loading,
|
|
#ip-api .loading {
|
|
color: #7f8c8d;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* IP information API card: the command, then its answer */
|
|
#ip-api p {
|
|
margin: 0;
|
|
color: #7f8c8d;
|
|
font-size: 14px;
|
|
}
|
|
#ip-api pre {
|
|
margin-top: 12px;
|
|
}
|
|
|
|
/* Footer */
|
|
.footer {
|
|
margin-top: 40px;
|
|
padding: 20px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
|
text-align: center;
|
|
color: #7f8c8d;
|
|
font-size: 14px;
|
|
}
|
|
.footer a {
|
|
color: #3498db;
|
|
text-decoration: none;
|
|
}
|
|
.footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.footer .separator {
|
|
margin: 0 10px;
|
|
color: #ddd;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="navbar-brand">
|
|
<a href="/">routewatch</a>
|
|
<span class="by">by</span>
|
|
<a href="https://sneak.berlin" class="author">@sneak</a>
|
|
</div>
|
|
<div class="navbar-links">
|
|
<a href="/" class="active">Home</a>
|
|
<a href="/status">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="main-content">
|
|
<div class="stats-grid">
|
|
<div class="stat-card" id="status-card">
|
|
<div class="stat-value">
|
|
<span id="stat-status">-</span>
|
|
<span id="freshness"></span>
|
|
</div>
|
|
<div class="stat-label">Status</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-routes">-</div>
|
|
<div class="stat-label">Live Routes</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-asns">-</div>
|
|
<div class="stat-label">Autonomous Systems</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-prefixes">-</div>
|
|
<div class="stat-label">Prefixes</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-peers">-</div>
|
|
<div class="stat-label">BGP Peers</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-updates">-</div>
|
|
<div class="stat-label">Updates/sec</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-section">
|
|
<div class="search-card">
|
|
<h2>AS Number Lookup</h2>
|
|
<form id="asn-form" class="search-input-group">
|
|
<input type="text" id="asn-input" placeholder="e.g., 15169 or AS15169" autocomplete="off">
|
|
<button type="submit">Lookup</button>
|
|
</form>
|
|
<p class="search-hint">Enter an AS number to view its announced prefixes and peers</p>
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>AS Name Search</h2>
|
|
<form id="asname-form" class="search-input-group">
|
|
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
<p class="search-hint">Search for autonomous systems by organization name or handle</p>
|
|
<div id="asname-results"></div>
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>IP Address Lookup</h2>
|
|
<form id="ip-form" class="search-input-group">
|
|
<input type="text" id="ip-input" placeholder="e.g., 8.8.8.8 or 2001:4860:4860::8888" autocomplete="off">
|
|
<button type="submit">Lookup</button>
|
|
</form>
|
|
<p class="search-hint">Get routing information for any IP address</p>
|
|
<div id="ip-result" class="ip-result">
|
|
<div class="ip-result-header">
|
|
<h3>Result</h3>
|
|
<button type="button" id="ip-result-close">Clear</button>
|
|
</div>
|
|
<pre id="ip-result-content"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-card" id="ip-api">
|
|
<h2>IP Information API</h2>
|
|
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
|
|
<pre id="ip-api-command" data-client-ip="{{.ClientIP}}"></pre>
|
|
<pre id="ip-api-answer" class="loading">Loading...</pre>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="footer">
|
|
<span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span>
|
|
<span class="separator">|</span>
|
|
<span>{{appLicense}}</span>
|
|
<span class="separator">|</span>
|
|
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
|
|
</footer>
|
|
|
|
<script nonce="{{.CSPNonce}}">
|
|
// The stats are fetched every REFRESH_PERIOD_MS. Next to the status, a
|
|
// spinner turns one frame each time an answer counts more messages
|
|
// than the one before. After STALE_AFTER_REFRESHES refreshes without
|
|
// such an answer, the spinner gives way to "stale" and the time since.
|
|
const REFRESH_PERIOD_MS = 5000;
|
|
const STALE_AFTER_REFRESHES = 3;
|
|
const MS_PER_SECOND = 1000;
|
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
|
|
|
|
let spinnerFrame = 0;
|
|
let lastMessageCount = null;
|
|
let lastFreshAt = Date.now();
|
|
|
|
// Turn the spinner when the message count has risen since the last answer.
|
|
function noteMessageCount(count) {
|
|
if (lastMessageCount !== null && count > lastMessageCount) {
|
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
lastFreshAt = Date.now();
|
|
}
|
|
lastMessageCount = count;
|
|
showFreshness();
|
|
}
|
|
|
|
function showFreshness() {
|
|
const freshness = document.getElementById('freshness');
|
|
const sinceFreshMs = Date.now() - lastFreshAt;
|
|
const stale = sinceFreshMs >= STALE_AFTER_REFRESHES * REFRESH_PERIOD_MS;
|
|
freshness.classList.toggle('stale', stale);
|
|
if (stale) {
|
|
freshness.textContent = 'stale for ' + Math.floor(sinceFreshMs / MS_PER_SECOND) + 's';
|
|
freshness.style.color = '';
|
|
return;
|
|
}
|
|
freshness.textContent = SPINNER_FRAMES[spinnerFrame];
|
|
freshness.style.color = SPINNER_COLOURS[spinnerFrame % SPINNER_COLOURS.length];
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
if (num >= 1000000) {
|
|
return (num / 1000000).toFixed(1) + 'M';
|
|
} else if (num >= 1000) {
|
|
return (num / 1000).toFixed(1) + 'K';
|
|
}
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
// Fetch and display stats
|
|
function updateStats() {
|
|
fetch('/api/v1/stats')
|
|
.then(response => response.json())
|
|
.then(response => {
|
|
if (response.status !== 'ok') return;
|
|
const data = response.data;
|
|
|
|
const statusCard = document.getElementById('status-card');
|
|
const statusEl = document.getElementById('stat-status');
|
|
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
|
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
|
|
noteMessageCount(data.total_messages);
|
|
|
|
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
|
|
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
|
|
document.getElementById('stat-prefixes').textContent = formatNumber(data.prefixes);
|
|
|
|
if (data.stream) {
|
|
document.getElementById('stat-peers').textContent = formatNumber(data.stream.bgp_peer_count);
|
|
}
|
|
|
|
const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec;
|
|
document.getElementById('stat-updates').textContent = Math.round(totalUpdates).toLocaleString();
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('stat-status').textContent = 'Error';
|
|
document.getElementById('status-card').className = 'stat-card disconnected';
|
|
});
|
|
}
|
|
|
|
// ASN lookup
|
|
document.getElementById('asn-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
let asn = document.getElementById('asn-input').value.trim();
|
|
// Remove 'AS' prefix if present
|
|
asn = asn.replace(/^AS/i, '');
|
|
if (asn && /^\d+$/.test(asn)) {
|
|
window.location.href = '/as/' + asn;
|
|
}
|
|
});
|
|
|
|
// AS name search. The handles and descriptions come from outside data,
|
|
// so the results are built with textContent, never innerHTML.
|
|
const asNameResults = document.getElementById('asname-results');
|
|
|
|
// Replace the search results with one line of text.
|
|
function showASNameMessage(text, className) {
|
|
const line = document.createElement('p');
|
|
line.className = className;
|
|
line.textContent = text;
|
|
asNameResults.replaceChildren(line);
|
|
}
|
|
|
|
// List the matches of a search answer, each as a link to its AS page,
|
|
// and say how many more matched than the answer holds.
|
|
function showASNameMatches(data) {
|
|
if (data.total === 0) {
|
|
showASNameMessage('No AS matches "' + data.query + '".', 'search-message');
|
|
return;
|
|
}
|
|
|
|
const list = document.createElement('ul');
|
|
list.className = 'asname-list';
|
|
for (const match of data.results) {
|
|
const asn = document.createElement('span');
|
|
asn.className = 'asn';
|
|
asn.textContent = 'AS' + match.asn;
|
|
|
|
const description = document.createElement('span');
|
|
description.className = 'description';
|
|
description.textContent = match.description;
|
|
|
|
const link = document.createElement('a');
|
|
link.href = '/as/' + match.asn;
|
|
link.append(asn, ' ' + match.handle, description);
|
|
|
|
const item = document.createElement('li');
|
|
item.append(link);
|
|
list.append(item);
|
|
}
|
|
asNameResults.replaceChildren(list);
|
|
|
|
const more = data.total - data.results.length;
|
|
if (more > 0) {
|
|
const line = document.createElement('p');
|
|
line.className = 'search-message';
|
|
line.textContent = more.toLocaleString() + ' more matched. Narrow the search to see them.';
|
|
asNameResults.append(line);
|
|
}
|
|
}
|
|
|
|
document.getElementById('asname-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
const query = document.getElementById('asname-input').value.trim();
|
|
if (!query) return;
|
|
|
|
// A number, or AS and a number, opens that AS's page
|
|
const asn = query.replace(/^AS/i, '');
|
|
if (/^\d+$/.test(asn)) {
|
|
window.location.href = '/as/' + asn;
|
|
return;
|
|
}
|
|
|
|
showASNameMessage('Searching...', 'loading');
|
|
|
|
fetch('/api/v1/search/as?q=' + encodeURIComponent(query))
|
|
.then(response => response.json())
|
|
.then(response => {
|
|
if (response.status === 'error') {
|
|
showASNameMessage('Error: ' + response.error.msg, 'error');
|
|
} else {
|
|
showASNameMatches(response.data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showASNameMessage('Error: ' + error.message, 'error');
|
|
});
|
|
});
|
|
|
|
// IP lookup
|
|
document.getElementById('ip-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
const ip = document.getElementById('ip-input').value.trim();
|
|
if (!ip) return;
|
|
|
|
const resultDiv = document.getElementById('ip-result');
|
|
const contentEl = document.getElementById('ip-result-content');
|
|
|
|
resultDiv.classList.add('visible');
|
|
contentEl.className = '';
|
|
contentEl.textContent = 'Loading...';
|
|
contentEl.classList.add('loading');
|
|
|
|
fetch('/ip/' + encodeURIComponent(ip))
|
|
.then(response => response.json())
|
|
.then(response => {
|
|
contentEl.classList.remove('loading');
|
|
if (response.status === 'error') {
|
|
contentEl.className = 'error';
|
|
contentEl.textContent = 'Error: ' + response.error.msg;
|
|
} else {
|
|
contentEl.className = '';
|
|
contentEl.textContent = JSON.stringify(response.data, null, 2);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
contentEl.classList.remove('loading');
|
|
contentEl.className = 'error';
|
|
contentEl.textContent = 'Error: ' + error.message;
|
|
});
|
|
});
|
|
|
|
// Close IP result
|
|
document.getElementById('ip-result-close').addEventListener('click', function() {
|
|
document.getElementById('ip-result').classList.remove('visible');
|
|
document.getElementById('ip-input').value = '';
|
|
});
|
|
|
|
// IP information API card: show the curl command that asks /ip/ about
|
|
// the visitor's own address, then make that same request and show the
|
|
// JSON it answers, an error answer included. The answer holds outside
|
|
// data, so it is set with textContent, never innerHTML.
|
|
const ipAPICommand = document.getElementById('ip-api-command');
|
|
const ipAPIAnswer = document.getElementById('ip-api-answer');
|
|
const ipAPIURL = window.location.origin + '/ip/' + ipAPICommand.dataset.clientIp;
|
|
|
|
ipAPICommand.textContent = 'curl ' + ipAPIURL;
|
|
|
|
fetch(ipAPIURL)
|
|
.then(response => response.json())
|
|
.then(answer => {
|
|
ipAPIAnswer.className = '';
|
|
ipAPIAnswer.textContent = JSON.stringify(answer, null, 2);
|
|
})
|
|
.catch(error => {
|
|
ipAPIAnswer.className = 'error';
|
|
ipAPIAnswer.textContent = 'Error: ' + error.message;
|
|
});
|
|
|
|
// Initial load and refresh stats every REFRESH_PERIOD_MS. The freshness
|
|
// check runs every second on its own, so a request that never returns
|
|
// still turns the spinner into "stale".
|
|
updateStats();
|
|
setInterval(updateStats, REFRESH_PERIOD_MS);
|
|
setInterval(showFreshness, MS_PER_SECOND);
|
|
</script>
|
|
</body>
|
|
</html>
|