openvibers/openvibe.tools Commits

Net tools: every catalogue tool works or says it is unavailable; myip, caching and tokens fixed. New (server/net/checks.js, all through the SSRF guard or DNS only): robots (fetch + RFC 9309 parse, groups, sitemaps, site-wide-block and unknown-directive warnings, test a path as a crawler; 512 KiB cap), sitemap (a bare domain uses robots.txt's Sitemap line, else /sitemap.xml; gzip, 10 MB download / 20 MB XML caps; root, namespace, loc, lastmod, changefreq, priority, 50,000 limit; an index is followed to its first 3 children), smtp (the domain's first MX or the host, port 25 or 587: greeting, EHLO extensions, STARTTLS with TLS version and certificate, AUTH after TLS, QUIT; never sends mail; egress gains connect() for a checked socket), blacklist (7 IPv4 DNSBLs + 3 domain lists, TXT reason, delisting links; refused and timed-out queries are 'unknown', not clean; IPv6 and private addresses are refused with a reason), uptime (one GET from this server, redirects re-checked: up/down, status, hops, time) and DNS propagation (ten public resolvers compared, the odd ones flagged). reputation, traceroute and mtr are marked unavailable (net/config.js status + reason; catalog.json status, the apex tool page and the tool page say so; traceroute/mtr need raw sockets). Every net tool now has an endpoint in net/config.js that net.html agrees with. Honest copy: ping and latency are timed TCP connections to port 443 from one server (not ICMP, not several regions), propagation compares public anycast resolvers, uptime checks once from one place; the family FAQ and hub copy no longer promise traceroute or regions. myip returns req.ip and the gateway trusts exactly one proxy hop (it trusted 2 and myip read the client's own first X-Forwarded-For entry); the custom-domain nginx template now sets X-Forwarded-For from $remote_addr. ip-api (or ipinfo over HTTPS when NET_IPINFO_TOKEN is set), RDAP and DoH answers are cached (net/cache.js: TTL + LRU, one upstream call per key at a time). createNetRoutes(null) could never load the ipinfo/Globalping tokens: they now come from NET_<KEY> in the environment first (Globalping is loaded, not used). Tests: net-tools.test.js (all mocked) and the SSRF test covers the new tools

OP OpenVibers committed 23 Sept 2026 · 480b772c61 · 15 files, +1323 −122


Changes

ddiff --git a/apps/_shared/egress.js b/apps/_shared/egress.js
iindex 8dc47e9..d47f967 100644
---- a/apps/_shared/egress.js
++++ b/apps/_shared/egress.js
@@@ -170,6 +170,20 @@ function createEgress({
});
}
++ /**
++ * An open TCP socket to a checked address, for tools that talk a protocol themselves (SMTP).
++ * → Promise<net.Socket> (connected; the caller owns it and must destroy it).
++ */
++ function connect(address, port, { timeoutMs = 8000 } = {}) {
++ assertChecked(address);
++ return new Promise((ok, fail) => {
++ const socket = tcpConnect({ host: address, port });
++ const timer = setTimeout(() => { socket.destroy(); fail(Object.assign(new Error('Connection timed out'), { code: 'ETIMEDOUT' })); }, timeoutMs);
++ socket.once('connect', () => { clearTimeout(timer); ok(socket); });
++ socket.once('error', (err) => { clearTimeout(timer); socket.destroy(); fail(err); });
++ });
++ }
++
/**
* TLS handshake with a checked address, SNI = the hostname. → Promise<{ cert, protocol, cipher }>.
* `target` is the result of resolve().
@@@ -273,7 +287,7 @@ function createEgress({
}
}
-- return { resolve, pinnedLookup, tcpProbe, tlsHandshake, parseUrl, request, follow, isAllowed };
++ return { resolve, pinnedLookup, tcpProbe, connect, tlsHandshake, parseUrl, request, follow, isAllowed };
}
module.exports = { createEgress, isPublicAddress, embeddedV4, normalizeHost, TargetRefused };
ddiff --git a/apps/gateway/.env.example b/apps/gateway/.env.example
iindex 1e65099..1906f14 100644
---- a/apps/gateway/.env.example
++++ b/apps/gateway/.env.example
@@@ -30,6 +30,11 @@ MEDIA_PUBLIC_URL=https://openvibe.media
# Internal API key for Network /internal/* calls (if ever needed)
INTERNAL_API_KEY=change-me-to-a-random-64-char-string
++# Net tools (optional). With an ipinfo.io token, IP lookups use ipinfo over HTTPS instead of ip-api.com's
++# free tier (plain HTTP). Answers are cached either way. The Globalping token is read but no tool uses it yet.
++#NET_IPINFO_TOKEN=
++#NET_GLOBALPING_TOKEN=
++
# Service registry (the catalog's service origins); defaults to OV_NETWORK_INTERNAL_URL + /api/v1/registry/services
#OV_REGISTRY_URL=http://127.0.0.1:4000/api/v1/registry/services
ddiff --git a/apps/gateway/public/net.html b/apps/gateway/public/net.html
iindex 903d154..df32e39 100644
---- a/apps/gateway/public/net.html
++++ b/apps/gateway/public/net.html
@@@ -215,6 +215,9 @@
border-radius: var(--radius-sm); color: var(--red); font-size: 14px;
}
++ /* A tool this server cannot run */
++ .tool-unavailable p { font-size: 14px; color: var(--text-secondary, #b0b0b8); margin: 8px 0 12px; line-height: 1.5; }
++
/* Pivot link */
.pivot {
display: inline-flex; align-items: center; gap: 4px;
@@@ -556,7 +559,7 @@
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card">
-- <div class="stat-value">40+</div>
++ <div class="stat-value" id="stat-tools">35</div>
<div class="stat-label">Tools</div>
</div>
<div class="stat-card">
@@@ -564,8 +567,8 @@
<div class="stat-label">Login Required</div>
</div>
<div class="stat-card">
-- <div class="stat-value">∞</div>
-- <div class="stat-label">Queries</div>
++ <div class="stat-value">1</div>
++ <div class="stat-label">Server, outside your network</div>
</div>
<div class="stat-card">
<div class="stat-value">⚡</div>
@@@ -634,7 +637,8 @@
const API_BASE = 'https://openvibe.tools/api/net';
-- // ── Tool definitions (mirrors server/net/config.js) ──────
++ // ── Tool definitions (mirrors server/net/config.js: endpoint and status must agree, which
++ // test/registry-consistency.test.js checks) ──────
const TOOLS = {
// Hub
net: { name: 'Network Tools', icon: 'fa-network-wired', cat: 'hub', endpoint: null, placeholder: 'Enter a domain, IP, or URL…' },
@@@ -649,13 +653,13 @@
isp: { name: 'ISP Lookup', icon: 'fa-building', cat: 'ip', endpoint: '/ip', placeholder: 'IP address or domain' },
asn: { name: 'ASN Lookup', icon: 'fa-diagram-project', cat: 'ip', endpoint: '/ip', placeholder: 'IP address or domain' },
rdns: { name: 'Reverse DNS Lookup', icon: 'fa-rotate-left', cat: 'ip', endpoint: '/rdns', placeholder: 'IP address for reverse DNS' },
-- whois: { name: 'Whois Lookup', icon: 'fa-address-book', cat: 'ip', endpoint: '/rdap', placeholder: 'Domain or IP' },
++ whois: { name: 'Whois Lookup', icon: 'fa-address-book', cat: 'ip', endpoint: '/whois', placeholder: 'Domain or IP' },
rdap: { name: 'RDAP Lookup', icon: 'fa-id-card', cat: 'ip', endpoint: '/rdap', placeholder: 'Domain or IP for RDAP lookup' },
// DNS
dns: { name: 'DNS Lookup', icon: 'fa-sitemap', cat: 'dns', endpoint: '/dns', placeholder: 'Domain to query' },
dig: { name: 'Online dig', icon: 'fa-terminal', cat: 'dns', endpoint: '/dns', placeholder: 'Domain (dig-style query)', dnsTypes: true },
nslookup: { name: 'NSLookup', icon: 'fa-magnifying-glass-arrow-right', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for nslookup' },
-- dnspropagation: { name: 'DNS Propagation Checker', icon: 'fa-globe', cat: 'dns', endpoint: '/dns', placeholder: 'Domain to check propagation' },
++ dnspropagation: { name: 'DNS Propagation Checker', icon: 'fa-globe', cat: 'dns', endpoint: '/dnsprop', placeholder: 'Domain to compare across public resolvers', propTypes: true },
mx: { name: 'MX Record Lookup', icon: 'fa-envelope', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for MX records', defaultTypes: 'MX' },
txt: { name: 'TXT Record Lookup', icon: 'fa-file-lines', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for TXT records', defaultTypes: 'TXT' },
ns: { name: 'Nameserver Lookup', icon: 'fa-server', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for NS records', defaultTypes: 'NS' },
@@@ -663,28 +667,40 @@
dkim: { name: 'DKIM Record Lookup', icon: 'fa-key', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for DKIM', defaultTypes: 'TXT' },
dmarc: { name: 'DMARC Checker', icon: 'fa-user-shield', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for DMARC', defaultTypes: 'TXT' },
// Active diagnostics
-- ping: { name: 'Ping Test', icon: 'fa-satellite-dish', cat: 'diag', endpoint: '/ping', placeholder: 'Host to ping' },
-- traceroute: { name: 'Traceroute', icon: 'fa-route', cat: 'diag', endpoint: '/ping', placeholder: 'Host for traceroute' },
-- mtr: { name: 'MTR Report', icon: 'fa-chart-line', cat: 'diag', endpoint: '/ping', placeholder: 'Host for MTR' },
++ ping: { name: 'Ping Test (TCP)', icon: 'fa-satellite-dish', cat: 'diag', endpoint: '/ping', placeholder: 'Host to ping (TCP port 443, from our server)' },
++ traceroute: { name: 'Traceroute', icon: 'fa-route', cat: 'diag', endpoint: null, unavailable: 'Traceroute needs raw network access that this server does not give the tools, so it is not available. The TCP ping test shows whether a host answers and how fast.', instead: 'ping' },
++ mtr: { name: 'MTR Report', icon: 'fa-chart-line', cat: 'diag', endpoint: null, unavailable: 'MTR needs raw network access that this server does not give the tools, so it is not available. The TCP ping test shows latency and loss to the host.', instead: 'ping' },
port: { name: 'Port Checker', icon: 'fa-door-open', cat: 'diag', endpoint: '/port', placeholder: 'Host to scan ports' },
headers: { name: 'HTTP Header Checker', icon: 'fa-list', cat: 'diag', endpoint: '/headers', placeholder: 'URL or domain' },
redirects: { name: 'Redirect Checker', icon: 'fa-share', cat: 'diag', endpoint: '/redirects', placeholder: 'URL to follow redirects' },
ssl: { name: 'SSL Certificate Checker', icon: 'fa-lock', cat: 'diag', endpoint: '/ssl', placeholder: 'Domain for SSL check' },
curl: { name: 'Online curl', icon: 'fa-download', cat: 'diag', endpoint: '/headers', placeholder: 'URL' },
httpstatus: { name: 'HTTP Status Codes', icon: 'fa-circle-check', cat: 'diag', endpoint: '/headers', placeholder: 'URL to check' },
-- latency: { name: 'Latency Test', icon: 'fa-gauge-high', cat: 'diag', endpoint: '/ping', placeholder: 'Host to measure latency' },
++ latency: { name: 'TCP Latency Test', icon: 'fa-gauge-high', cat: 'diag', endpoint: '/ping', placeholder: 'Host (10 timed TCP connections to port 443)', count: 10 },
++ // Email & reputation
++ blacklist: { name: 'Blacklist Checker', icon: 'fa-ban', cat: 'mail', endpoint: '/blacklist', placeholder: 'IPv4 address or domain' },
++ reputation: { name: 'Domain Reputation Checker', icon: 'fa-star', cat: 'mail', endpoint: null, unavailable: 'A combined reputation report is not available yet. The blacklist, SPF, DKIM and DMARC checkers cover its parts.', instead: 'blacklist' },
++ smtp: { name: 'SMTP Server Test', icon: 'fa-paper-plane', cat: 'mail', endpoint: '/smtp', placeholder: 'Mail server or domain (its MX is used)', smtpPorts: true },
++ // Website checks
++ uptime: { name: 'Website Uptime Checker', icon: 'fa-heart-pulse', cat: 'web', endpoint: '/uptime', placeholder: 'URL or domain' },
++ robots: { name: 'Robots.txt Tester', icon: 'fa-robot', cat: 'web', endpoint: '/robots', placeholder: 'Domain or URL', robotsTest: true },
++ sitemap: { name: 'Sitemap Validator', icon: 'fa-sitemap', cat: 'web', endpoint: '/sitemap', placeholder: 'Sitemap URL or domain' },
};
const CATS = {
ip: { label: 'IP / Identity / Ownership', icon: 'fa-fingerprint' },
dns: { label: 'DNS Tools', icon: 'fa-sitemap' },
diag: { label: 'Active Diagnostics', icon: 'fa-satellite-dish' },
++ mail: { label: 'Email & Reputation', icon: 'fa-envelope' },
++ web: { label: 'Website Checks', icon: 'fa-globe' },
};
// ── Detect active tool from subdomain ────────────────────
const host = location.hostname;
const subdomain = host.replace(/\.openvibe\.tools$/, '');
-- let activeTool = TOOLS[subdomain] ? subdomain : 'net';
++ // The gateway names the tool in <meta name="ov-tool"> (descriptive hosts, mirrors, custom domains).
++ const META_TOOL = (document.querySelector('meta[name="ov-tool"]') || {}).content || '';
++ let activeTool = TOOLS[META_TOOL] ? META_TOOL : TOOLS[subdomain] ? subdomain : 'net';
// Aliases
if (subdomain === 'network') activeTool = 'net';
if (subdomain === 'ptr') activeTool = 'rdns';
@@@ -738,6 +754,8 @@
if (isHub) {
document.getElementById('showcase-section').style.display = '';
buildShowcaseGrid();
++ const n = Object.values(TOOLS).filter(t => t.endpoint && !t.unavailable).length;
++ document.getElementById('stat-tools').textContent = String(n);
}
// If tool auto-runs (myip), do it immediately
@@@ -782,9 +800,29 @@
if (tool.placeholder) {
searchInput.placeholder = tool.placeholder;
}
-- if (tool.auto) {
++ if (tool.auto || tool.unavailable) {
document.getElementById('search-bar').style.display = 'none';
}
++ // A tool this server cannot run: say so where the results would be, and point at what works.
++ if (tool.unavailable) {
++ const box = document.createElement('div');
++ box.className = 'card tool-unavailable';
++ box.setAttribute('role', 'status');
++ const head = document.createElement('div');
++ head.className = 'card-head';
++ head.textContent = 'Not available';
++ const p = document.createElement('p');
++ p.textContent = tool.unavailable;
++ box.append(head, p);
++ if (tool.instead && TOOLS[tool.instead]) {
++ const a = document.createElement('a');
++ a.className = 'pivot';
++ a.href = `https://${tool.instead}.openvibe.tools`;
++ a.textContent = `Open the ${TOOLS[tool.instead].name}`;
++ box.append(a);
++ }
++ document.getElementById('results').append(box);
++ }
if (tool.btnText) {
btnGo.innerHTML = `<i class="fa-solid fa-magnifying-glass"></i> ${tool.btnText}`;
}
@@@ -802,7 +840,7 @@
}).join('')}</div>`;
} else {
// Tool page: show related tools as mini-nav
-- const related = Object.entries(TOOLS).filter(([,t]) => t.cat === tool.cat && !t.auto);
++ const related = Object.entries(TOOLS).filter(([,t]) => t.cat === tool.cat && !t.auto && !t.unavailable);
if (related.length > 1) {
chipsArea.innerHTML = `<div class="tool-selector">${related.map(([id, t]) => {
const active = id === activeTool ? ' active' : '';
@@@ -811,6 +849,23 @@
}
}
++ // Tool options
++ const opts = document.getElementById('options-area');
++ if (tool.propTypes) {
++ opts.innerHTML = `<div class="options-row"><label for="prop-type">Record type:</label>
++ <select id="prop-type">${['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT'].map(t => `<option value="${t}">${t}</option>`).join('')}</select></div>`;
++ }
++ if (tool.smtpPorts) {
++ opts.innerHTML = `<div class="options-row"><label for="smtp-port">Port:</label>
++ <select id="smtp-port"><option value="25">25 (server to server)</option><option value="587">587 (submission)</option></select></div>`;
++ }
++ if (tool.robotsTest) {
++ opts.innerHTML = `<div class="options-row"><label for="robots-path">Test a path (optional):</label>
++ <input id="robots-path" type="text" placeholder="/private/page.html" spellcheck="false" style="min-width:200px">
++ <label for="robots-ua">as</label>
++ <select id="robots-ua"><option value="*">any crawler (*)</option><option>Googlebot</option><option>Bingbot</option><option>GPTBot</option></select></div>`;
++ }
++
// DNS type options
if (tool.dnsTypes) {
const types = ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'PTR', 'CAA', 'SRV'];
@@@ -836,7 +891,7 @@
<div class="tc-icon"><i class="fa-solid ${t.icon}"></i></div>
<div class="tc-info">
<h3>${t.name}</h3>
-- <p>${t.placeholder || ''}</p>
++ <p>${t.unavailable ? 'Not available' : (t.placeholder || '')}</p>
<div class="tc-domain">${id}.openvibe.tools</div>
</div>
</a>
@@@ -866,7 +921,7 @@
return;
}
-- if (!tool.endpoint) return;
++ if (!tool.endpoint || tool.unavailable) return;
if (!target && !tool.auto) { toast('Please enter a domain, IP, or URL', 'error'); return; }
// Show loading
@@@ -897,6 +952,13 @@
}
// SPF: just TXT
if (activeTool === 'spf') params.set('types', 'TXT');
++ if (tool.count) params.set('count', String(tool.count));
++ if (tool.propTypes) params.set('type', document.getElementById('prop-type').value);
++ if (tool.smtpPorts) params.set('port', document.getElementById('smtp-port').value);
++ if (tool.robotsTest) {
++ const p = document.getElementById('robots-path').value.trim();
++ if (p) { params.set('path', p); params.set('ua', document.getElementById('robots-ua').value); }
++ }
const qs = params.toString();
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
@@@ -932,6 +994,12 @@
if (tool.endpoint === '/redirects') return renderRedirects(el, data);
if (tool.endpoint === '/port') return renderPort(el, data);
if (tool.endpoint === '/ping') return renderPing(el, data);
++ if (tool.endpoint === '/dnsprop') return renderDnsProp(el, data);
++ if (tool.endpoint === '/blacklist') return renderBlacklist(el, data);
++ if (tool.endpoint === '/smtp') return renderSMTP(el, data);
++ if (tool.endpoint === '/uptime') return renderUptime(el, data);
++ if (tool.endpoint === '/robots') return renderRobots(el, data);
++ if (tool.endpoint === '/sitemap') return renderSitemap(el, data);
// Fallback: raw JSON
el.innerHTML = `<div class="card"><div class="raw-block">${esc(JSON.stringify(data, null, 2))}</div></div>`;
@@@ -1223,6 +1291,139 @@
el.innerHTML = html;
}
++ // ── Website, mail and DNS checks ─────────────────────────
++ const row = (k, v) => `<tr><td class="key">${esc(k)}</td><td class="val">${v}</td></tr>`;
++ const from = (data) => data.checkedFrom ? `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Checked from ${esc(data.checkedFrom)}.</p>` : '';
++
++ function renderDnsProp(el, data) {
++ const list = data.resolvers || [];
++ let html = `<div class="badges">
++ <span class="badge badge-cyan">${esc(data.target)} ${esc(data.type)}</span>
++ <span class="badge ${data.agree ? 'badge-green' : 'badge-yellow'}">${data.agree ? 'All resolvers agree' : 'Resolvers disagree'}</span>
++ <span class="badge">${data.answered}/${list.length} answered</span>
++ </div>`;
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-globe"></i> Public resolvers</div>
++ <table class="res-table"><tr><th>Resolver</th><th>Answer</th><th>Time</th></tr>
++ ${list.map(r => `<tr>
++ <td class="val">${esc(r.name)}<br><span style="color:var(--text-muted);font-size:11px">${esc(r.ip)}</span></td>
++ <td class="val">${r.status === 'ok' ? r.answers.map(a => esc(a)).join('<br>') : `<span class="badge ${r.status === 'error' ? 'badge-red' : ''}">${esc(r.status === 'error' ? (r.error || 'error') : r.status === 'nxdomain' ? 'NXDOMAIN (no such name)' : 'no record')}</span>`}
++ ${r.differs ? ' <span class="badge badge-yellow">differs</span>' : ''}</td>
++ <td class="val">${r.ms}ms</td></tr>`).join('')}
++ </table>${from(data)}</div>`;
++ el.innerHTML = html;
++ }
++
++ function renderBlacklist(el, data) {
++ const lists = data.lists || [];
++ let html = `<div class="badges">
++ <span class="badge badge-cyan">${esc(data.domain || data.ip || data.target)}${data.domain && data.ip ? ` (${esc(data.ip)})` : ''}</span>
++ <span class="badge ${data.listedCount ? 'badge-red' : 'badge-green'}">${data.listedCount ? `Listed on ${data.listedCount}` : 'Not listed'}</span>
++ ${data.unknownCount ? `<span class="badge badge-yellow">${data.unknownCount} could not be checked</span>` : ''}
++ </div>`;
++ if (data.domain && !data.ip) html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.domain)} has no IPv4 address, so only the domain lists were checked.</p>`;
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-ban"></i> Blacklists</div>
++ <table class="res-table"><tr><th>List</th><th>Checked</th><th>Result</th></tr>
++ ${lists.map(l => `<tr>
++ <td class="val">${esc(l.name)}<br><span style="color:var(--text-muted);font-size:11px">${esc(l.zone)}</span></td>
++ <td class="val">${esc(l.query)}</td>
++ <td>${l.listed === true ? `<span class="badge badge-red">Listed</span> ${l.reason ? `<div style="font-size:12px;margin-top:4px">${esc(l.reason)}</div>` : ''} <a class="pivot" href="${esc(l.delist)}" target="_blank" rel="noopener">Delisting</a>`
++ : l.listed === false ? '<span class="badge badge-green">Not listed</span>'
++ : `<span class="badge badge-yellow" title="${esc(l.error || '')}">Unknown</span><div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(l.error || '')}</div>`}</td>
++ </tr>`).join('')}
++ </table>${from(data)}</div>`;
++ el.innerHTML = html;
++ }
++
++ function renderSMTP(el, data) {
++ let html = `<div class="badges">
++ <span class="badge badge-cyan">${esc(data.host)}:${data.port} (${esc(data.ip)})</span>
++ <span class="badge ${data.connected ? 'badge-green' : 'badge-red'}">${data.connected ? 'Connected' : 'No connection'}</span>
++ ${data.starttls ? `<span class="badge ${data.starttls.ok ? 'badge-green' : data.starttls.offered ? 'badge-yellow' : 'badge-red'}">${data.starttls.ok ? 'STARTTLS ' + esc(data.starttls.protocol || '') : data.starttls.offered ? 'STARTTLS failed' : 'No STARTTLS'}</span>` : ''}
++ </div>`;
++ if (data.mx && data.mx.length) html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.target)} receives mail at ${data.mx.map(m => `${esc(m.exchange)} (${m.priority})`).join(', ')}; tested the first.</p>`;
++ if (data.error) html += `<div class="error-msg" style="margin-bottom:12px">${esc(data.error)}</div>`;
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-paper-plane"></i> Conversation</div><table class="res-table">
++ ${data.connectMs != null ? row('Connect time', `${data.connectMs}ms`) : ''}
++ ${data.banner ? row('Greeting', `${data.banner.code} ${esc(data.banner.text)}`) : ''}
++ ${data.ehlo ? row('EHLO', `${data.ehlo.code} ${esc(data.ehlo.greeting)}`) : ''}
++ ${data.ehlo ? row('Extensions', (data.starttls && data.starttls.extensions ? data.starttls.extensions : data.ehlo.extensions).map(e => esc(e)).join('<br>') || '—') : ''}
++ ${data.auth ? row('Login methods', data.auth.length ? data.auth.map(esc).join(', ') : 'none offered') : ''}
++ </table></div>`;
++ const c = data.starttls && data.starttls.certificate;
++ if (c) {
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-lock"></i> STARTTLS certificate</div><table class="res-table">
++ ${row('Protocol', esc(data.starttls.protocol || '—'))}${row('Cipher', esc(data.starttls.cipher || '—'))}
++ ${row('Subject', esc(c.subject || '—'))}${row('Issuer', esc(c.issuer || '—'))}
++ ${row('Valid to', `${esc(c.validTo || '—')}${c.daysLeft != null ? ` (${c.daysLeft} days)` : ''}`)}
++ ${row('Trusted chain', c.trusted ? '✅ yes' : `❌ no${c.error ? ' — ' + esc(c.error) : ''}`)}
++ ${c.hostnameMatch != null ? row('Name matches', c.hostnameMatch ? '✅ yes' : '❌ no') : ''}
++ </table></div>`;
++ }
++ el.innerHTML = html + from(data);
++ }
++
++ function renderUptime(el, data) {
++ let html = `<div class="badges">
++ <span class="badge ${data.state === 'up' ? 'badge-green' : 'badge-red'}">${data.state === 'up' ? '🟢 Up' : '🔴 Down'}</span>
++ ${data.status ? `<span class="badge">HTTP ${data.status} ${esc(data.statusText || '')}</span>` : ''}
++ <span class="badge badge-cyan">⚡ ${data.ms}ms</span>
++ </div>`;
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-heart-pulse"></i> ${esc(data.url)}</div><table class="res-table">
++ ${data.error ? row('Problem', esc(data.error)) : ''}
++ ${data.finalUrl && data.finalUrl !== data.url ? row('Ended at', esc(data.finalUrl)) : ''}
++ ${data.redirects ? row('Redirects', (data.chain || []).map(h => `${h.status} ${esc(h.url)}`).join('<br>')) : ''}
++ ${data.server ? row('Server', esc(data.server)) : ''}
++ ${row('Checked at', esc(data.checkedAt || ''))}
++ </table>${from(data)}</div>`;
++ el.innerHTML = html;
++ }
++
++ function renderRobots(el, data) {
++ let html = `<div class="badges">
++ <span class="badge ${data.found ? 'badge-green' : 'badge-yellow'}">HTTP ${data.status}</span>
++ <span class="badge badge-cyan">${esc(data.verdict || '')}</span>
++ ${data.found ? `<span class="badge">${data.size} bytes</span>` : ''}
++ </div>`;
++ if (data.test) {
++ const t = data.test;
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-vial"></i> ${esc(t.path)} as ${esc(t.ua)}</div>
++ <p><span class="badge ${t.allowed ? 'badge-green' : 'badge-red'}">${t.allowed ? 'Allowed' : 'Blocked'}</span>
++ ${t.rule ? ` by <code>${esc(t.rule.type)}: ${esc(t.rule.path)}</code> (line ${t.rule.line})` : ' (no rule matches)'}</p></div>`;
++ }
++ for (const w of data.warnings || []) html += `<div class="error-msg" style="margin-bottom:8px;text-align:left">${w.line ? `Line ${w.line}: ` : ''}${esc(w.text)}</div>`;
++ for (const g of data.groups || []) {
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-robot"></i> User-agent: ${g.agents.map(esc).join(', ')}</div>
++ <table class="res-table">${g.rules.map(r => `<tr><td class="key">${esc(r.type)}</td><td class="val">${esc(r.path) || '<span style="color:var(--text-muted)">(empty: allows everything)</span>'}</td></tr>`).join('') || '<tr><td class="val">No rules</td></tr>'}
++ ${g.crawlDelay ? row('crawl-delay', esc(g.crawlDelay)) : ''}</table></div>`;
++ }
++ if ((data.sitemaps || []).length) html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> Sitemaps</div>${data.sitemaps.map(u => `<div class="raw-block">${esc(u)}</div>`).join('')}</div>`;
++ el.innerHTML = html + from(data);
++ }
++
++ function renderSitemap(el, data) {
++ const issues = data.issues || [];
++ const errors = issues.filter(i => i.level === 'error');
++ let html = `<div class="badges">
++ <span class="badge ${data.valid ? 'badge-green' : 'badge-red'}">${data.valid ? 'Valid' : `${errors.length} problem${errors.length === 1 ? '' : 's'}`}</span>
++ ${data.kind ? `<span class="badge badge-cyan">${data.kind === 'index' ? `Sitemap index: ${data.count} sitemaps` : `${Number(data.count).toLocaleString('en-US')} URLs`}</span>` : ''}
++ <span class="badge">HTTP ${data.status}${data.gzip ? ' · gzip' : ''}</span>
++ </div>`;
++ html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.finalUrl || data.url)}${data.source === 'robots.txt' ? ' (from robots.txt)' : data.source === 'default' ? ' (the default location)' : ''}</p>`;
++ for (const i of issues) {
++ html += `<div class="card"><div class="card-head"><span class="badge ${i.level === 'error' ? 'badge-red' : 'badge-yellow'}">${i.level}</span> ${esc(i.message)}${i.count > 1 ? ` (${i.count}×)` : ''}</div>
++ ${i.examples && i.examples.length ? i.examples.map(x => `<div class="raw-block">${esc(x)}</div>`).join('') : ''}</div>`;
++ }
++ if ((data.sample || []).length) {
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-list"></i> First entries</div><table class="res-table">
++ ${data.sample.map(u => `<tr><td class="val">${esc(u.loc)}</td><td class="val">${esc(u.lastmod || '')}</td></tr>`).join('')}</table></div>`;
++ }
++ if ((data.childResults || []).length) {
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> First ${data.childResults.length} of ${data.childrenTotal} sitemaps</div><table class="res-table">
++ ${data.childResults.map(c => `<tr><td class="val">${esc(c.url)}</td><td>${c.error ? `<span class="badge badge-red">${esc(c.error)}</span>` : `<span class="badge ${c.valid ? 'badge-green' : 'badge-red'}">${c.valid ? 'valid' : 'problems'}</span> ${Number(c.count || 0).toLocaleString('en-US')} URLs`}</td></tr>`).join('')}</table></div>`;
++ }
++ el.innerHTML = html + from(data);
++ }
++
function renderLookup(el, data) {
// Supertool: tabbed view
const sections = [];
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 9dbb442..13a7a3b 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -78,7 +78,10 @@ function getRequestHost(req) {
}
// ── Security ─────────────────────────────────────────────────
--app.set('trust proxy', 2); // Cloudflare → Nginx → Node
++// One hop: the host's nginx, which applies Cloudflare's real IP (real_ip_header CF-Connecting-IP) and
++// sets X-Forwarded-For to $remote_addr. req.ip is that address; anything a client put in its own
++// X-Forwarded-For stays to the left of it and is never believed (myip, rate limits, forwarded IPs).
++app.set('trust proxy', 1);
app.use(helmet({
contentSecurityPolicy: {
ddiff --git a/apps/gateway/server/net/cache.js b/apps/gateway/server/net/cache.js
nnew file mode 100644
iindex 0000000..66361a0
---- /dev/null
++++ b/apps/gateway/server/net/cache.js
@@@ -0,0 +1,52 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Net.OpenVibe — a small in-memory TTL cache for upstream lookups (ip-api / ipinfo, RDAP,
++// DNS-over-HTTPS, DNSBL answers). Least recently used entries go first once `max` is reached;
++// concurrent asks for the same key share one upstream call.
++// ═══════════════════════════════════════════════════════════════
++
++function createCache({ max = 1000, ttlMs = 10 * 60_000, now = Date.now } = {}) {
++ const map = new Map(); // key → { value, expires }, oldest first
++ const inflight = new Map(); // key → Promise
++
++ function get(key) {
++ const e = map.get(key);
++ if (!e) return undefined;
++ if (e.expires <= now()) { map.delete(key); return undefined; }
++ map.delete(key); map.set(key, e); // most recently used last
++ return e.value;
++ }
++
++ function set(key, value, ttl = ttlMs) {
++ map.delete(key);
++ if (!(ttl > 0)) return;
++ map.set(key, { value, expires: now() + ttl });
++ while (map.size > max) map.delete(map.keys().next().value);
++ }
++
++ /**
++ * The cached value, or fn()'s — kept for `ttl` ms (a number, or a function of the value; 0 = do
++ * not keep it, e.g. an upstream error). A rejected fn() is not cached.
++ */
++ function wrap(key, fn, ttl = ttlMs) {
++ const hit = get(key);
++ if (hit !== undefined) return Promise.resolve(hit);
++ if (inflight.has(key)) return inflight.get(key);
++ const p = (async () => {
++ try {
++ const value = await fn();
++ set(key, value, typeof ttl === 'function' ? ttl(value) : ttl);
++ return value;
++ } finally {
++ inflight.delete(key);
++ }
++ })();
++ inflight.set(key, p);
++ return p;
++ }
++
++ return { get, set, wrap, size: () => map.size, clear: () => { map.clear(); inflight.clear(); } };
++}
++
++module.exports = { createCache };
ddiff --git a/apps/gateway/server/net/checks.js b/apps/gateway/server/net/checks.js
nnew file mode 100644
iindex 0000000..355e091
---- /dev/null
++++ b/apps/gateway/server/net/checks.js
@@@ -0,0 +1,541 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Net.OpenVibe — the checks that had catalogue pages but no implementation:
++// robots fetch + parse robots.txt (RFC 9309), test a path against it
++// sitemap fetch + check a sitemap or sitemap index (size-capped, gzip aware)
++// smtp greeting, EHLO, STARTTLS (TLS version + certificate) on port 25 or 587; never sends mail
++// blacklist DNSBL lookups for an IPv4 address or a domain
++// uptime is a site up right now, from this server (status, redirects, time)
++// dnsprop the same record from ten public resolvers, compared
++// Everything that connects to a visitor's target (robots, sitemap, smtp, uptime) goes through the SSRF
++// guard (_shared/egress): public addresses only, checked after DNS, dialled as checked, redirects
++// re-checked. blacklist and dnsprop only ask DNS servers questions; the target is never contacted.
++// ═══════════════════════════════════════════════════════════════
++
++const dns = require('dns');
++const net = require('net');
++const tls = require('tls');
++const zlib = require('zlib');
++const { TargetRefused } = require('../../../_shared/egress');
++const { createCache } = require('./cache');
++
++const UA = 'Mozilla/5.0 (compatible; Net.OpenVibe/1.0; +https://net.openvibe.tools)';
++const FROM = 'the OpenVibe server (one location)';
++const ROBOTS_MAX_BYTES = 512 * 1024; // Google reads the first 500 KiB
++const SITEMAP_MAX_BYTES = 10 * 1024 * 1024; // downloaded
++const SITEMAP_MAX_XML = 20 * 1024 * 1024; // after gunzip
++const SITEMAP_CHILDREN = 3; // index files: the first N children are checked too
++const SMTP_PORTS = [25, 587];
++
++const bad = (message, status = 400) => Object.assign(new Error(message), { status });
++const upstream = (what, err) => Object.assign(new Error(`${what}: ${(err && (err.code || err.message)) || 'failed'}`), { status: 502 });
++
++/** A domain or URL typed by a person → URL (https unless they said http). */
++function toUrl(egress, input) {
++ const raw = String(input || '').trim();
++ if (!raw) throw bad('Please provide a URL or domain');
++ return egress.parseUrl(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
++}
++
++function defaultResolver(servers, { timeout = 3000, tries = 2 } = {}) {
++ const r = new dns.promises.Resolver({ timeout, tries });
++ if (servers) r.setServers(servers);
++ return r;
++}
++
++// ── robots.txt (RFC 9309) ────────────────────────────────────
++
++function parseRobots(text) {
++ const groups = [], sitemaps = [], warnings = [];
++ let current = null, lastWasAgent = false;
++ text.split(/\r\n|\r|\n/).forEach((raw, i) => {
++ const line = raw.replace(/#.*$/, '').trim();
++ if (!line) return;
++ const m = /^([A-Za-z][A-Za-z-]*)\s*:\s*(.*)$/.exec(line);
++ if (!m) { warnings.push({ line: i + 1, text: `Not a rule: ${raw.trim().slice(0, 80)}` }); return; }
++ const key = m[1].toLowerCase(), value = m[2].trim();
++ if (key === 'user-agent') {
++ if (!current || !lastWasAgent) { current = { agents: [], rules: [], crawlDelay: null, line: i + 1 }; groups.push(current); }
++ current.agents.push(value);
++ lastWasAgent = true;
++ return;
++ }
++ lastWasAgent = false;
++ if (key === 'sitemap') { sitemaps.push(value); return; }
++ if (key === 'allow' || key === 'disallow') {
++ if (!current) { warnings.push({ line: i + 1, text: `${m[1]} before any User-agent line is ignored by crawlers` }); return; }
++ current.rules.push({ type: key, path: value, line: i + 1 });
++ return;
++ }
++ if (key === 'crawl-delay') { if (current) current.crawlDelay = value; return; }
++ warnings.push({ line: i + 1, text: `"${m[1]}" is not a robots.txt rule most crawlers read` });
++ });
++ for (const g of groups) {
++ if (g.agents.includes('*') && g.rules.some(r => r.type === 'disallow' && r.path === '/') && !g.rules.some(r => r.type === 'allow' && r.path && r.path !== '/')) {
++ warnings.unshift({ line: g.line, text: 'Every crawler is blocked from the whole site (User-agent: * with Disallow: /)' });
++ }
++ }
++ return { groups, sitemaps, warnings };
++}
++
++function ruleRegex(pattern) {
++ const anchored = pattern.endsWith('$');
++ const body = (anchored ? pattern.slice(0, -1) : pattern).split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
++ return new RegExp(`^${body}${anchored ? '$' : ''}`);
++}
++
++/** Would `ua` be allowed to fetch `path`? The most specific group, the longest matching rule; Allow wins ties. */
++function testRobots(parsed, path, ua = '*') {
++ if (path === '/robots.txt') return { allowed: true, rule: null, agents: [] };
++ const token = String(ua || '*').toLowerCase().split('/')[0].trim();
++ let matched = parsed.groups.filter(g => g.agents.some(a => a.toLowerCase() === token && token !== '*'));
++ if (!matched.length) matched = parsed.groups.filter(g => g.agents.includes('*'));
++ let best = null;
++ for (const g of matched) for (const r of g.rules) {
++ if (!r.path) continue; // an empty Disallow allows everything
++ if (!ruleRegex(r.path).test(path)) continue;
++ if (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.type === 'allow')) best = r;
++ }
++ return { allowed: !best || best.type === 'allow', rule: best, agents: [...new Set(matched.flatMap(g => g.agents))] };
++}
++
++// ── sitemap ──────────────────────────────────────────────────
++
++const W3C_DATE = /^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$/;
++const FREQS = new Set(['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']);
++const decodeXml = (s) => s.replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, '$1').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
++
++function analyseSitemap(xml, baseUrl) {
++ const issues = new Map();
++ const issue = (level, code, message, example) => {
++ let e = issues.get(code);
++ if (!e) issues.set(code, e = { level, code, message, count: 0, examples: [] });
++ e.count++;
++ if (example != null && e.examples.length < 5) e.examples.push(String(example).slice(0, 200));
++ };
++ const kind = /<urlset[\s>]/.test(xml) ? 'urlset' : /<sitemapindex[\s>]/.test(xml) ? 'index' : null;
++ if (!kind) {
++ issue('error', 'not-a-sitemap', /<html[\s>]/i.test(xml) ? 'This is an HTML page, not a sitemap' : 'No <urlset> or <sitemapindex> root element');
++ return { kind: null, count: 0, sample: [], children: [], issues: [...issues.values()], valid: false };
++ }
++ if (!/xmlns\s*=\s*["']https?:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9["']/.test(xml)) issue('warning', 'namespace', 'The sitemaps.org namespace (xmlns="http://www.sitemaps.org/schemas/sitemap/0.9") is missing');
++ const host = new URL(baseUrl).hostname;
++ const re = kind === 'urlset' ? /<url(?:\s[^>]*)?>([\s\S]*?)<\/url>/g : /<sitemap(?:\s[^>]*)?>([\s\S]*?)<\/sitemap>/g;
++ const sample = [], children = [];
++ let count = 0, m;
++ while ((m = re.exec(xml))) {
++ count++;
++ const body = m[1];
++ const locRaw = (/<loc>\s*([\s\S]*?)\s*<\/loc>/i.exec(body) || [])[1];
++ if (locRaw == null) { issue('error', 'missing-loc', 'An entry has no <loc>'); continue; }
++ if (/&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/i.test(locRaw) && !/^<!\[CDATA\[/.test(locRaw)) issue('error', 'unescaped-ampersand', 'A <loc> has an unescaped & (must be &amp;): the file is not valid XML', locRaw);
++ const loc = decodeXml(locRaw);
++ let u = null;
++ try { u = new URL(loc); } catch { /* below */ }
++ if (!u || !/^https?:$/.test(u.protocol)) issue('error', 'bad-loc', '<loc> must be an absolute http(s) URL', loc);
++ else if (u.hostname !== host) issue('warning', 'other-host', `<loc> on another host than the sitemap (${host}): search engines ignore those unless the other site is verified`, loc);
++ if (loc.length > 2048) issue('error', 'long-loc', '<loc> is longer than 2,048 characters', loc.slice(0, 80) + '…');
++ const lastmod = (/<lastmod>\s*([^<]*?)\s*<\/lastmod>/i.exec(body) || [])[1];
++ if (lastmod != null && !W3C_DATE.test(lastmod)) issue('error', 'bad-lastmod', '<lastmod> is not a W3C date (e.g. 2026-09-23 or 2026-09-23T10:00:00+00:00)', lastmod);
++ const freq = (/<changefreq>\s*([^<]*?)\s*<\/changefreq>/i.exec(body) || [])[1];
++ if (freq != null && !FREQS.has(freq.toLowerCase())) issue('warning', 'bad-changefreq', '<changefreq> is not one of always, hourly, daily, weekly, monthly, yearly, never', freq);
++ const pri = (/<priority>\s*([^<]*?)\s*<\/priority>/i.exec(body) || [])[1];
++ if (pri != null && !(/^(0(\.\d+)?|1(\.0+)?)$/.test(pri))) issue('warning', 'bad-priority', '<priority> must be between 0.0 and 1.0', pri);
++ if (sample.length < 10) sample.push({ loc, ...(lastmod != null && { lastmod }) });
++ if (kind === 'index' && u) children.push(loc);
++ }
++ if (!count) issue('warning', 'empty', kind === 'index' ? 'The sitemap index lists no sitemaps' : 'The sitemap lists no URLs');
++ if (count > 50000) issue('error', 'too-many', `${count.toLocaleString('en-US')} entries: one sitemap may hold at most 50,000`);
++ const list = [...issues.values()];
++ return { kind, count, sample, children, issues: list, valid: !list.some(i => i.level === 'error') };
++}
++
++// ── SMTP ─────────────────────────────────────────────────────
++
++/** Reads SMTP replies (multi-line: "250-…" continues, "250 …" ends) from a socket. */
++function smtpReader(sock) {
++ let buf = '', ended = null;
++ const waiters = [];
++ const take = () => {
++ const lines = [];
++ let at = 0;
++ for (;;) {
++ const nl = buf.indexOf('\n', at);
++ if (nl < 0) return null;
++ const line = buf.slice(at, nl).replace(/\r$/, '');
++ at = nl + 1;
++ lines.push(line);
++ if (!/^\d{3}-/.test(line)) {
++ buf = buf.slice(at);
++ return { code: parseInt(line.slice(0, 3), 10) || 0, lines: lines.map(l => l.slice(4)) };
++ }
++ }
++ };
++ const pump = () => { while (waiters.length) { const r = take(); if (!r) return; waiters.shift().ok(r); } };
++ const fail = (e) => { if (!ended) ended = e; while (waiters.length) waiters.shift().no(ended); };
++ const onData = (d) => { buf += d.toString('latin1'); if (buf.length > 64 * 1024) fail(new Error('The server sent too much')); else pump(); };
++ const onError = (e) => fail(e);
++ const onClose = () => fail(new Error('The server closed the connection'));
++ sock.on('data', onData); sock.on('error', onError); sock.on('close', onClose);
++ return {
++ read(timeoutMs = 10_000) {
++ const r = take();
++ if (r) return Promise.resolve(r);
++ if (ended) return Promise.reject(ended);
++ return new Promise((ok, no) => {
++ const w = { ok: (v) => { clearTimeout(t); ok(v); }, no: (e) => { clearTimeout(t); no(e); } };
++ const t = setTimeout(() => { const i = waiters.indexOf(w); if (i >= 0) waiters.splice(i, 1); no(Object.assign(new Error('The server did not answer in time'), { code: 'ETIMEDOUT' })); }, timeoutMs);
++ waiters.push(w);
++ });
++ },
++ send(line) { sock.write(`${line}\r\n`); },
++ detach() { sock.removeListener('data', onData); sock.removeListener('error', onError); sock.removeListener('close', onClose); },
++ };
++}
++
++function certSummary(cert, host, authorized, authorizationError) {
++ if (!cert || !cert.subject) return null;
++ const validTo = cert.valid_to ? new Date(cert.valid_to) : null;
++ let hostnameMatch = null;
++ if (host && !net.isIP(host)) { try { hostnameMatch = !tls.checkServerIdentity(host, cert); } catch { hostnameMatch = false; } }
++ return {
++ subject: cert.subject.CN || null,
++ issuer: (cert.issuer && (cert.issuer.O || cert.issuer.CN)) || null,
++ validTo: cert.valid_to || null,
++ daysLeft: validTo ? Math.ceil((validTo - Date.now()) / 86400000) : null,
++ sans: cert.subjectaltname ? cert.subjectaltname.split(', ').map(s => s.replace(/^DNS:/, '')).slice(0, 20) : [],
++ trusted: !!authorized,
++ hostnameMatch,
++ ...(authorizationError && { error: String(authorizationError) }),
++ };
++}
++
++// ── DNSBL ────────────────────────────────────────────────────
++
++const IP_LISTS = [
++ { zone: 'zen.spamhaus.org', name: 'Spamhaus ZEN', delist: 'https://check.spamhaus.org/', refused: ['127.255.255.252', '127.255.255.254', '127.255.255.255'] },
++ { zone: 'bl.spamcop.net', name: 'SpamCop', delist: 'https://www.spamcop.net/bl.shtml' },
++ { zone: 'psbl.surriel.com', name: 'PSBL', delist: 'https://psbl.org/' },
++ { zone: 'bl.mailspike.net', name: 'Mailspike', delist: 'https://mailspike.org/iplookup.html' },
++ { zone: 'dnsbl.dronebl.org', name: 'DroneBL', delist: 'https://dronebl.org/lookup' },
++ { zone: 'all.s5h.net', name: 's5h.net', delist: 'https://www.usenix.org.uk/content/rbl.html' },
++ { zone: 'dnsbl-1.uceprotect.net', name: 'UCEPROTECT Level 1', delist: 'https://www.uceprotect.net/en/rblcheck.php' },
++];
++const DOMAIN_LISTS = [
++ { zone: 'dbl.spamhaus.org', name: 'Spamhaus DBL', delist: 'https://check.spamhaus.org/', refused: ['127.255.255.252', '127.255.255.254', '127.255.255.255'] },
++ { zone: 'multi.surbl.org', name: 'SURBL', delist: 'https://surbl.org/surbl-analysis', refused: ['127.0.0.1'] },
++ { zone: 'multi.uribl.com', name: 'URIBL', delist: 'https://admin.uribl.com/', refused: ['127.0.0.1'] },
++];
++
++// ── DNS propagation ──────────────────────────────────────────
++
++const RESOLVERS = [
++ { id: 'google', name: 'Google', ip: '8.8.8.8' },
++ { id: 'cloudflare', name: 'Cloudflare', ip: '1.1.1.1' },
++ { id: 'quad9', name: 'Quad9', ip: '9.9.9.9' },
++ { id: 'opendns', name: 'OpenDNS (Cisco)', ip: '208.67.222.222' },
++ { id: 'adguard', name: 'AdGuard (unfiltered)', ip: '94.140.14.140' },
++ { id: 'controld', name: 'Control D (unfiltered)', ip: '76.76.2.0' },
++ { id: 'dnssb', name: 'DNS.SB', ip: '185.222.222.222' },
++ { id: 'he', name: 'Hurricane Electric', ip: '74.82.42.42' },
++ { id: 'level3', name: 'Lumen (Level 3)', ip: '4.2.2.1' },
++ { id: 'alibaba', name: 'Alibaba (China)', ip: '223.5.5.5' },
++];
++const PROP_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT'];
++
++function normalizeAnswers(type, answers) {
++ const list = (answers || []).map((a) => {
++ if (type === 'MX') return `${a.priority} ${a.exchange}`;
++ if (type === 'TXT') return Array.isArray(a) ? a.join('') : String(a);
++ return String(a);
++ });
++ return list.sort();
++}
++
++// ─────────────────────────────────────────────────────────────
++
++/**
++ * @param {object} o
++ * @param {object} o.egress the SSRF guard (createEgress())
++ * @param {(servers?: string[], opts?) => dns.promises.Resolver} [o.resolverFor] DNS resolvers (tests pass mocks)
++ * @param {Function} [o.tlsUpgrade] tls.connect (tests pass a mock)
++ */
++function createChecks({ egress, resolverFor = defaultResolver, tlsUpgrade = tls.connect, cache = createCache({ max: 2000 }) }) {
++ const system = () => resolverFor(null, { timeout: 3000, tries: 2 });
++
++ // ── robots ──
++ async function robots(target, { path: testPath, ua } = {}) {
++ const base = toUrl(egress, target);
++ const url = `${base.origin}/robots.txt`;
++ let r;
++ try {
++ r = await egress.follow(url, { method: 'GET', headers: { 'User-Agent': UA }, timeoutMs: 10_000, maxBytes: ROBOTS_MAX_BYTES, maxRedirects: 5 });
++ } catch (err) {
++ if (err instanceof TargetRefused || (err.status >= 400 && err.status < 500)) throw err;
++ throw upstream('Could not fetch robots.txt', err);
++ }
++ const out = { url, finalUrl: r.url, status: r.status, found: r.status >= 200 && r.status < 300, size: r.body.length, truncated: r.truncated, checkedFrom: FROM };
++ if (!out.found) {
++ out.verdict = r.status >= 500
++ ? `robots.txt answered HTTP ${r.status}: Google treats the whole site as disallowed until it answers again.`
++ : `No robots.txt (HTTP ${r.status}): crawlers may crawl everything.`;
++ out.groups = []; out.sitemaps = []; out.warnings = [];
++ } else {
++ const ct = String(r.headers['content-type'] || '');
++ const parsed = parseRobots(r.body.toString('utf8'));
++ Object.assign(out, parsed);
++ if (/text\/html/i.test(ct)) out.warnings.unshift({ line: null, text: 'robots.txt is served as text/html: this looks like a web page, not a robots file' });
++ if (r.truncated) out.warnings.unshift({ line: null, text: 'robots.txt is larger than 500 KiB: crawlers ignore everything after that' });
++ out.verdict = parsed.groups.length ? `${parsed.groups.length} group${parsed.groups.length === 1 ? '' : 's'} of rules` : 'No rules: crawlers may crawl everything.';
++ }
++ if (testPath) {
++ let p = String(testPath).trim();
++ if (/^https?:\/\//i.test(p)) { try { const u = new URL(p); p = u.pathname + u.search; } catch { throw bad('The path to test is not a valid URL'); } }
++ if (!p.startsWith('/')) p = `/${p}`;
++ const agent = String(ua || '*').slice(0, 100);
++ const verdict = out.found ? testRobots(out, p, agent) : { allowed: r.status < 500, rule: null, agents: [] };
++ out.test = { path: p, ua: agent, allowed: verdict.allowed, rule: verdict.rule, matchedAgents: verdict.agents };
++ }
++ return out;
++ }
++
++ // ── sitemap ──
++ async function fetchSitemap(href) {
++ let r;
++ try {
++ r = await egress.follow(href, { method: 'GET', headers: { 'User-Agent': UA, Accept: 'application/xml,text/xml,*/*' }, timeoutMs: 15_000, maxBytes: SITEMAP_MAX_BYTES, maxRedirects: 5 });
++ } catch (err) {
++ if (err instanceof TargetRefused || (err.status >= 400 && err.status < 500)) throw err;
++ throw upstream('Could not fetch the sitemap', err);
++ }
++ const out = { url: href, finalUrl: r.url, status: r.status, bytes: r.body.length, truncated: r.truncated };
++ if (r.status < 200 || r.status >= 300) return { ...out, kind: null, count: 0, issues: [{ level: 'error', code: 'http', message: `The sitemap answered HTTP ${r.status}`, count: 1, examples: [] }], valid: false };
++ let body = r.body;
++ if (body[0] === 0x1f && body[1] === 0x8b) {
++ try { body = zlib.gunzipSync(body, { maxOutputLength: SITEMAP_MAX_XML }); out.gzip = true; } catch (err) {
++ const big = err instanceof RangeError || err.code === 'ERR_BUFFER_TOO_LARGE';
++ return { ...out, kind: null, count: 0, issues: [{ level: 'error', code: big ? 'too-large' : 'gzip', message: big ? `Larger than ${SITEMAP_MAX_XML / 1024 / 1024} MB uncompressed: only smaller sitemaps are checked here` : 'The gzip data is damaged', count: 1, examples: [] }], valid: false };
++ }
++ }
++ const a = analyseSitemap(body.toString('utf8').replace(/^/, ''), r.url);
++ if (r.truncated) a.issues.unshift({ level: 'warning', code: 'truncated', message: `Larger than ${SITEMAP_MAX_BYTES / 1024 / 1024} MB: only the first ${SITEMAP_MAX_BYTES / 1024 / 1024} MB were checked`, count: 1, examples: [] });
++ return { ...out, ...a };
++ }
++
++ async function sitemap(target) {
++ let url = toUrl(egress, target);
++ let source = 'given';
++ if (url.pathname === '/' && !url.search) {
++ // A bare site: the Sitemap line of its robots.txt, else /sitemap.xml.
++ try {
++ const rb = await egress.follow(`${url.origin}/robots.txt`, { method: 'GET', headers: { 'User-Agent': UA }, timeoutMs: 8000, maxBytes: ROBOTS_MAX_BYTES, maxRedirects: 3 });
++ const first = rb.status >= 200 && rb.status < 300 ? parseRobots(rb.body.toString('utf8')).sitemaps[0] : null;
++ if (first) { url = egress.parseUrl(new URL(first, url.origin).href); source = 'robots.txt'; }
++ } catch (err) { if (err instanceof TargetRefused) throw err; }
++ if (source === 'given') { url = new URL('/sitemap.xml', url.origin); source = 'default'; }
++ }
++ const main = await fetchSitemap(url.href);
++ const result = { ...main, source, checkedFrom: FROM };
++ if (main.kind === 'index') {
++ result.childResults = [];
++ for (const child of (main.children || []).slice(0, SITEMAP_CHILDREN)) {
++ try {
++ const c = await fetchSitemap(child);
++ result.childResults.push({ url: child, status: c.status, kind: c.kind, count: c.count, valid: c.valid, issues: c.issues });
++ } catch (err) {
++ result.childResults.push({ url: child, error: err instanceof TargetRefused ? err.message : (err.message || 'failed') });
++ }
++ }
++ result.childrenTotal = (main.children || []).length;
++ }
++ delete result.children;
++ return result;
++ }
++
++ // ── smtp ──
++ async function smtp(target, { port } = {}) {
++ const p = parseInt(port, 10) || 25;
++ if (!SMTP_PORTS.includes(p)) throw bad('The SMTP test uses port 25 or 587');
++ let host = target, mx = null;
++ if (!net.isIP(target)) {
++ try {
++ const records = await system().resolveMx(target);
++ if (records && records.length) {
++ mx = records.sort((a, b) => a.priority - b.priority).map(r => ({ exchange: r.exchange, priority: r.priority })).slice(0, 10);
++ if (mx[0].exchange) host = mx[0].exchange;
++ }
++ } catch { /* no MX: the name itself is the mail server */ }
++ }
++ const dest = await egress.resolve(host, { prefer: 4 });
++ const out = { target, host, ...(mx && { mx }), ip: dest.address, port: p, checkedFrom: FROM };
++ const t0 = Date.now();
++ let sock;
++ try {
++ sock = await egress.connect(dest.address, p, { timeoutMs: 10_000 });
++ } catch (err) {
++ return { ...out, connected: false, error: err.code === 'ECONNREFUSED' ? 'Connection refused' : err.code === 'ETIMEDOUT' ? 'No answer (timed out): the port is filtered, or outbound mail ports are blocked on the way' : (err.code || err.message) };
++ }
++ out.connected = true;
++ out.connectMs = Date.now() - t0;
++ let secure = null;
++ try {
++ let rd = smtpReader(sock);
++ const greet = await rd.read();
++ out.banner = { code: greet.code, text: greet.lines.join('\n').slice(0, 500) };
++ if (greet.code !== 220) { out.error = `The server greeted with ${greet.code} instead of 220`; return out; }
++ rd.send('EHLO net.openvibe.tools');
++ const ehlo = await rd.read();
++ const ext = ehlo.code === 250 ? ehlo.lines.slice(1).map(l => l.trim()).filter(Boolean).slice(0, 40) : [];
++ out.ehlo = { code: ehlo.code, greeting: (ehlo.lines[0] || '').slice(0, 200), extensions: ext };
++ const has = (name) => ext.some(e => e.toUpperCase().split(/[\s=]/)[0] === name);
++ const authOf = (list) => { const a = list.find(e => /^AUTH[\s=]/i.test(e)); return a ? a.slice(5).trim().split(/\s+/) : []; };
++ out.auth = authOf(ext);
++ out.starttls = { offered: has('STARTTLS') };
++ if (out.starttls.offered) {
++ rd.send('STARTTLS');
++ const go = await rd.read();
++ if (go.code !== 220) { out.starttls.error = `STARTTLS answered ${go.code}`; } else {
++ rd.detach();
++ secure = await new Promise((ok, no) => {
++ const s = tlsUpgrade({ socket: sock, servername: net.isIP(host) ? undefined : host, rejectUnauthorized: false });
++ const timer = setTimeout(() => { s.destroy(); no(Object.assign(new Error('TLS handshake timed out'), { code: 'ETIMEDOUT' })); }, 10_000);
++ s.once('secureConnect', () => { clearTimeout(timer); ok(s); });
++ s.once('error', (e) => { clearTimeout(timer); no(e); });
++ });
++ const cipher = secure.getCipher && secure.getCipher();
++ Object.assign(out.starttls, {
++ ok: true,
++ protocol: secure.getProtocol ? secure.getProtocol() : null,
++ cipher: cipher ? cipher.name : null,
++ certificate: certSummary(secure.getPeerCertificate && secure.getPeerCertificate(), host, secure.authorized, secure.authorizationError),
++ });
++ rd = smtpReader(secure);
++ rd.send('EHLO net.openvibe.tools');
++ const again = await rd.read();
++ if (again.code === 250) {
++ const ext2 = again.lines.slice(1).map(l => l.trim()).filter(Boolean).slice(0, 40);
++ out.starttls.extensions = ext2;
++ out.auth = authOf(ext2);
++ }
++ }
++ }
++ rd.send('QUIT');
++ await rd.read(3000).catch(() => {});
++ } catch (err) {
++ out.error = err.code === 'ETIMEDOUT' ? err.message : `The conversation stopped: ${(err.message || err.code || 'error').slice(0, 160)}`;
++ if (out.starttls && out.starttls.offered && out.starttls.ok === undefined && /TLS|SSL|handshake/i.test(String(err.message))) out.starttls.error = String(err.message).slice(0, 160);
++ } finally {
++ out.ms = Date.now() - t0;
++ try { (secure || sock).destroy(); } catch { /* gone */ }
++ try { sock.destroy(); } catch { /* gone */ }
++ }
++ return out;
++ }
++
++ // ── blacklist ──
++ async function queryList(list, name) {
++ const q = `${name}.${list.zone}`;
++ const r = system();
++ return cache.wrap(`dnsbl:${q}`, async () => {
++ let codes;
++ try { codes = await r.resolve4(q); } catch (err) {
++ if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') return { listed: false, codes: [] };
++ return { listed: null, codes: [], error: err.code || err.message };
++ }
++ const refused = codes.filter(c => (list.refused || []).includes(c));
++ if (refused.length === codes.length) return { listed: null, codes, error: 'This list refused the query (it does not answer through our resolver); check it on its own site' };
++ const hits = codes.filter(c => c.startsWith('127.') && !(list.refused || []).includes(c));
++ if (!hits.length) return { listed: false, codes };
++ let reason = null;
++ try { reason = (await r.resolveTxt(q)).map(t => t.join('')).join(' ').slice(0, 300) || null; } catch { /* no reason published */ }
++ return { listed: true, codes: hits, reason };
++ }, (v) => (v.error && v.listed === null && !/refused/.test(v.error) ? 0 : 10 * 60_000));
++ }
++
++ async function blacklist(target) {
++ let ip = null, domain = null;
++ if (net.isIP(target)) {
++ if (net.isIPv6(target)) throw bad('Most blacklists list IPv4 addresses only: enter an IPv4 address or a domain');
++ if (!egress.isAllowed(target)) throw bad(`${target} is a private or reserved address; those are never on public blacklists`);
++ ip = target;
++ } else {
++ domain = target.replace(/^www\./, '');
++ try { ip = (await system().resolve4(target))[0] || null; } catch { ip = null; }
++ if (ip && !egress.isAllowed(ip)) ip = null;
++ }
++ const checks = [];
++ if (ip) {
++ const rev = ip.split('.').reverse().join('.');
++ for (const l of IP_LISTS) checks.push(queryList(l, rev).then(v => ({ zone: l.zone, name: l.name, type: 'ip', query: ip, delist: l.delist, ...v })));
++ }
++ if (domain) for (const l of DOMAIN_LISTS) checks.push(queryList(l, domain).then(v => ({ zone: l.zone, name: l.name, type: 'domain', query: domain, delist: l.delist, ...v })));
++ const lists = await Promise.all(checks);
++ return {
++ target, kind: domain ? 'domain' : 'ip', ip, ...(domain && { domain }),
++ listedCount: lists.filter(l => l.listed === true).length,
++ cleanCount: lists.filter(l => l.listed === false).length,
++ unknownCount: lists.filter(l => l.listed === null).length,
++ lists, checkedFrom: FROM,
++ };
++ }
++
++ // ── uptime ──
++ async function uptime(target) {
++ const url = toUrl(egress, target);
++ const t0 = Date.now();
++ try {
++ const r = await egress.follow(url.href, { method: 'GET', headers: { 'User-Agent': UA, Accept: 'text/html,*/*;q=0.8' }, timeoutMs: 15_000, maxRedirects: 5 });
++ return {
++ url: url.href, state: r.status < 500 ? 'up' : 'down', status: r.status, statusText: r.statusText,
++ finalUrl: r.url, redirects: Math.max(0, r.chain.length - 1), chain: r.chain,
++ ms: Date.now() - t0, server: r.headers.server || null, checkedFrom: FROM, checkedAt: new Date().toISOString(),
++ };
++ } catch (err) {
++ if (err instanceof TargetRefused) throw err;
++ if (err.status === 400 && !err.cause) throw err; // not a valid URL
++ const why = err.cause ? 'The name does not resolve (DNS)'
++ : err.name === 'TimeoutError' || /timeout/i.test(err.message) ? 'No answer within 15 seconds'
++ : err.code === 'ECONNREFUSED' ? 'Connection refused'
++ : /certificate|SSL|TLS/i.test(String(err.code) + err.message) ? `TLS problem: ${err.code || err.message}`
++ : (err.code || err.message || 'Could not connect');
++ return { url: url.href, state: 'down', status: null, error: why, ms: Date.now() - t0, checkedFrom: FROM, checkedAt: new Date().toISOString() };
++ }
++ }
++
++ // ── dnsprop ──
++ async function dnsprop(target, { type } = {}) {
++ if (net.isIP(target)) throw bad('Enter a domain name: propagation is about the records of a name');
++ const t = String(type || 'A').toUpperCase();
++ if (!PROP_TYPES.includes(t)) throw bad(`Record type must be one of ${PROP_TYPES.join(', ')}`);
++ return cache.wrap(`prop:${t}:${target}`, async () => {
++ const resolvers = await Promise.all(RESOLVERS.map(async (res) => {
++ const t0 = Date.now();
++ try {
++ const answers = normalizeAnswers(t, await resolverFor([res.ip], { timeout: 4000, tries: 1 }).resolve(target, t));
++ return { ...res, status: 'ok', answers, ms: Date.now() - t0 };
++ } catch (err) {
++ const status = err.code === 'ENOTFOUND' ? 'nxdomain' : err.code === 'ENODATA' ? 'nodata' : 'error';
++ return { ...res, status, answers: [], ms: Date.now() - t0, ...(status === 'error' && { error: err.code || err.message }) };
++ }
++ }));
++ // The majority answer (errors do not vote); everyone else "differs".
++ const key = (r) => (r.status === 'ok' ? JSON.stringify(r.answers) : r.status);
++ const votes = new Map();
++ for (const r of resolvers) if (r.status !== 'error') votes.set(key(r), (votes.get(key(r)) || 0) + 1);
++ const majority = [...votes.entries()].sort((a, b) => b[1] - a[1])[0];
++ for (const r of resolvers) r.differs = r.status !== 'error' && !!majority && key(r) !== majority[0];
++ return {
++ target, type: t, resolvers,
++ agree: votes.size <= 1,
++ answered: resolvers.filter(r => r.status !== 'error').length,
++ majority: majority ? { answer: majority[0].startsWith('[') ? JSON.parse(majority[0]) : majority[0], count: majority[1] } : null,
++ checkedFrom: FROM,
++ };
++ }, 20_000);
++ }
++
++ return { robots, sitemap, smtp, blacklist, uptime, dnsprop };
++}
++
++module.exports = { createChecks, parseRobots, testRobots, analyseSitemap, smtpReader, IP_LISTS, DOMAIN_LISTS, RESOLVERS, PROP_TYPES, SMTP_PORTS };
ddiff --git a/apps/gateway/server/net/config.js b/apps/gateway/server/net/config.js
iindex 0f52c82..58c4336 100644
---- a/apps/gateway/server/net/config.js
++++ b/apps/gateway/server/net/config.js
@@@ -11,10 +11,14 @@ const { seoFor } = require('../seo/catalog');
// ═══════════════════════════════════════════════════════════════
/**
-- * Load a config value from the DB site_settings table, falling
-- * back to the provided default. All net.* keys are namespaced.
++ * A config value: the environment first (NET_<KEY>, e.g. NET_IPINFO_TOKEN in /etc/openvibe/tools.env),
++ * then a site_settings row `net.<key>` when a database is given, else the default. The gateway has
++ * no database, so without the environment the optional tokens never loaded.
*/
function getSetting(db, key, fallback = '') {
++ const env = process.env[`NET_${key.toUpperCase()}`];
++ if (env) return env;
++ if (!db) return fallback;
try {
const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(`net.${key}`);
return row ? row.value : fallback;
@@@ -24,12 +28,14 @@ function getSetting(db, key, fallback = '') {
function getNetConfig(db) {
return {
// ── External API keys (optional: free tiers work without keys) ──
++ // With an ipinfo token, IP lookups use ipinfo.io over HTTPS; without, ip-api.com's free tier,
++ // which only answers over plain HTTP. Either way answers are cached (net/cache.js).
ipinfo: {
token: getSetting(db, 'ipinfo_token', ''), // ipinfo.io — 50k/month free
baseUrl: 'https://ipinfo.io',
},
ipapi: {
-- baseUrl: 'http://ip-api.com', // ip-api.com — free (non-commercial)
++ baseUrl: 'http://ip-api.com', // ip-api.com — free (non-commercial), HTTP only
},
// RDAP is keyless (IANA standard, public registries)
rdap: {
@@@ -40,7 +46,8 @@ function getNetConfig(db) {
google: 'https://dns.google/resolve',
cloudflare: 'https://cloudflare-dns.com/dns-query',
},
-- // Globalping (free tier: 100 credits/hour)
++ // Globalping (free tier: 100 credits/hour). Loaded, not yet used by any tool: multi-region
++ // ping/traceroute through it would be a new feature, not a fix.
globalping: {
token: getSetting(db, 'globalping_token', ''),
baseUrl: 'https://api.globalping.io/v1',
@@@ -75,51 +82,54 @@ function getNetConfig(db) {
}
// ── Tool definitions (drives the UI, routing, and directory) ──
++// endpoint: the /api/net route the tool's page calls (net.html's TOOLS must agree; test/registry-
++// consistency.test.js checks both against the router). status 'unavailable': no implementation, and
++// the page, the catalog and /api/net/tools say so with the `unavailable` text.
const NET_TOOLS = [
// Hub
{ id: 'net', subdomain: 'net', name: 'Net.OpenVibe', icon: 'fa-network-wired', desc: 'Network & Internet Diagnostics Hub', hub: true },
-- { id: 'lookup', subdomain: 'lookup', name: 'OpenVibeLookup', icon: 'fa-magnifying-glass', desc: 'All-in-one domain/IP lookup supertool' },
++ { id: 'lookup', subdomain: 'lookup', name: 'OpenVibeLookup', icon: 'fa-magnifying-glass', desc: 'All-in-one domain/IP lookup supertool', endpoint: '/lookup' },
// IP / Identity
-- { id: 'myip', subdomain: 'myip', name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs', desc: 'Your public IP, ISP, location & reverse DNS' },
-- { id: 'ip', subdomain: 'ip', name: 'OpenVibeIP', icon: 'fa-at', desc: 'IP address lookup & details (IPv4 & IPv6)' },
-- { id: 'ipv4', subdomain: 'ipv4', name: 'OpenVibeIPv4', icon: 'fa-hashtag', desc: 'IPv4 address lookup, class, CIDR calculator' },
-- { id: 'ipv6', subdomain: 'ipv6', name: 'OpenVibeIPv6', icon: 'fa-code', desc: 'IPv6 address lookup, expansion, type detection' },
-- { id: 'geoip', subdomain: 'geoip', name: 'OpenVibeGeoIP', icon: 'fa-earth-americas', desc: 'IP geolocation — country, city, timezone, map' },
-- { id: 'hostname', subdomain: 'hostname', name: 'OpenVibeHostname', icon: 'fa-server', desc: 'Hostname & reverse DNS lookup' },
-- { id: 'isp', subdomain: 'isp', name: 'OpenVibeISP', icon: 'fa-building', desc: 'ISP & organization info from IP' },
-- { id: 'asn', subdomain: 'asn', name: 'OpenVibeASN', icon: 'fa-diagram-project', desc: 'Autonomous System Number lookup' },
-- { id: 'rdns', subdomain: 'rdns', name: 'OpenVibeReverseDNS', icon: 'fa-rotate-left', desc: 'Reverse DNS (PTR) lookup for IPv4 & IPv6' },
-- { id: 'whois', subdomain: 'whois', name: 'OpenVibeWhois', icon: 'fa-address-book', desc: 'Whois domain registration lookup' },
-- { id: 'rdap', subdomain: 'rdap', name: 'OpenVibeRDAP', icon: 'fa-id-card', desc: 'RDAP — modern registration data lookup' },
++ { id: 'myip', subdomain: 'myip', name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs', desc: 'Your public IP, ISP, location & reverse DNS', endpoint: '/myip' },
++ { id: 'ip', subdomain: 'ip', name: 'OpenVibeIP', icon: 'fa-at', desc: 'IP address lookup & details (IPv4 & IPv6)', endpoint: '/ip' },
++ { id: 'ipv4', subdomain: 'ipv4', name: 'OpenVibeIPv4', icon: 'fa-hashtag', desc: 'IPv4 address lookup, class, CIDR calculator', endpoint: '/ipv4' },
++ { id: 'ipv6', subdomain: 'ipv6', name: 'OpenVibeIPv6', icon: 'fa-code', desc: 'IPv6 address lookup, expansion, type detection', endpoint: '/ipv6' },
++ { id: 'geoip', subdomain: 'geoip', name: 'OpenVibeGeoIP', icon: 'fa-earth-americas', desc: 'IP geolocation — country, city, timezone, map', endpoint: '/ip' },
++ { id: 'hostname', subdomain: 'hostname', name: 'OpenVibeHostname', icon: 'fa-server', desc: 'Hostname & reverse DNS lookup', endpoint: '/rdns' },
++ { id: 'isp', subdomain: 'isp', name: 'OpenVibeISP', icon: 'fa-building', desc: 'ISP & organization info from IP', endpoint: '/ip' },
++ { id: 'asn', subdomain: 'asn', name: 'OpenVibeASN', icon: 'fa-diagram-project', desc: 'Autonomous System Number lookup', endpoint: '/ip' },
++ { id: 'rdns', subdomain: 'rdns', name: 'OpenVibeReverseDNS', icon: 'fa-rotate-left', desc: 'Reverse DNS (PTR) lookup for IPv4 & IPv6', endpoint: '/rdns' },
++ { id: 'whois', subdomain: 'whois', name: 'OpenVibeWhois', icon: 'fa-address-book', desc: 'Whois domain registration lookup', endpoint: '/whois' },
++ { id: 'rdap', subdomain: 'rdap', name: 'OpenVibeRDAP', icon: 'fa-id-card', desc: 'RDAP — modern registration data lookup', endpoint: '/rdap' },
// DNS
-- { id: 'dns', subdomain: 'dns', name: 'OpenVibeDNS', icon: 'fa-sitemap', desc: 'DNS record lookup — A, AAAA, MX, TXT, and more' },
-- { id: 'dig', subdomain: 'dig', name: 'OpenVibeDig', icon: 'fa-terminal', desc: 'Online dig command — query any DNS record' },
-- { id: 'nslookup', subdomain: 'nslookup', name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right', desc: 'NSLookup — simple DNS query tool' },
-- { id: 'dnspropagation', subdomain: 'dnspropagation', name: 'OpenVibeDNSPropagation', icon: 'fa-globe', desc: 'Check DNS propagation across world regions' },
-- { id: 'mx', subdomain: 'mx', name: 'OpenVibeMX', icon: 'fa-envelope', desc: 'MX record lookup — mail server discovery' },
-- { id: 'txt', subdomain: 'txt', name: 'OpenVibeTXT', icon: 'fa-file-lines', desc: 'TXT record lookup — SPF, DKIM, verification' },
-- { id: 'ns', subdomain: 'ns', name: 'OpenVibeNS', icon: 'fa-server', desc: 'NS record lookup — nameserver discovery' },
-- { id: 'spf', subdomain: 'spf', name: 'OpenVibeSPF', icon: 'fa-shield-halved', desc: 'SPF record checker & validator' },
-- { id: 'dkim', subdomain: 'dkim', name: 'OpenVibeDKIM', icon: 'fa-key', desc: 'DKIM record lookup & validation' },
-- { id: 'dmarc', subdomain: 'dmarc', name: 'OpenVibeDMARC', icon: 'fa-user-shield', desc: 'DMARC policy checker' },
++ { id: 'dns', subdomain: 'dns', name: 'OpenVibeDNS', icon: 'fa-sitemap', desc: 'DNS record lookup — A, AAAA, MX, TXT, and more', endpoint: '/dns' },
++ { id: 'dig', subdomain: 'dig', name: 'OpenVibeDig', icon: 'fa-terminal', desc: 'Online dig command — query any DNS record', endpoint: '/dns' },
++ { id: 'nslookup', subdomain: 'nslookup', name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right', desc: 'NSLookup — simple DNS query tool', endpoint: '/dns' },
++ { id: 'dnspropagation', subdomain: 'dnspropagation', name: 'OpenVibeDNSPropagation', icon: 'fa-globe', desc: 'Check DNS propagation across world regions', endpoint: '/dnsprop' },
++ { id: 'mx', subdomain: 'mx', name: 'OpenVibeMX', icon: 'fa-envelope', desc: 'MX record lookup — mail server discovery', endpoint: '/dns' },
++ { id: 'txt', subdomain: 'txt', name: 'OpenVibeTXT', icon: 'fa-file-lines', desc: 'TXT record lookup — SPF, DKIM, verification', endpoint: '/dns' },
++ { id: 'ns', subdomain: 'ns', name: 'OpenVibeNS', icon: 'fa-server', desc: 'NS record lookup — nameserver discovery', endpoint: '/dns' },
++ { id: 'spf', subdomain: 'spf', name: 'OpenVibeSPF', icon: 'fa-shield-halved', desc: 'SPF record checker & validator', endpoint: '/dns' },
++ { id: 'dkim', subdomain: 'dkim', name: 'OpenVibeDKIM', icon: 'fa-key', desc: 'DKIM record lookup & validation', endpoint: '/dns' },
++ { id: 'dmarc', subdomain: 'dmarc', name: 'OpenVibeDMARC', icon: 'fa-user-shield', desc: 'DMARC policy checker', endpoint: '/dns' },
// Active diagnostics
-- { id: 'ping', subdomain: 'ping', name: 'OpenVibePing', icon: 'fa-satellite-dish', desc: 'Ping from multiple global regions' },
-- { id: 'traceroute', subdomain: 'traceroute', name: 'OpenVibeTraceroute', icon: 'fa-route', desc: 'Traceroute — visualize network path & hops' },
-- { id: 'mtr', subdomain: 'mtr', name: 'OpenVibeMTR', icon: 'fa-chart-line', desc: 'MTR — combined ping + traceroute analysis' },
-- { id: 'port', subdomain: 'port', name: 'OpenVibePortCheck', icon: 'fa-door-open', desc: 'Port scanner — check if ports are open' },
-- { id: 'headers', subdomain: 'headers', name: 'OpenVibeHeaders', icon: 'fa-list', desc: 'HTTP headers checker — security & cache analysis' },
-- { id: 'redirects', subdomain: 'redirects', name: 'OpenVibeRedirects', icon: 'fa-share', desc: 'Redirect chain tracer — follow all hops' },
-- { id: 'ssl', subdomain: 'ssl', name: 'OpenVibeSSL', icon: 'fa-lock', desc: 'SSL/TLS certificate checker & chain inspector' },
-- { id: 'curl', subdomain: 'curl', name: 'OpenVibeCurl', icon: 'fa-download', desc: 'Online curl — HTTP request tester' },
-- { id: 'httpstatus', subdomain: 'httpstatus', name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check', desc: 'HTTP status code reference & checker' },
-- { id: 'latency', subdomain: 'latency', name: 'OpenVibeLatency', icon: 'fa-gauge-high', desc: 'Latency tester — measure response times' },
++ { id: 'ping', subdomain: 'ping', name: 'OpenVibePing', icon: 'fa-satellite-dish', desc: 'Ping from multiple global regions', endpoint: '/ping' },
++ { id: 'traceroute', subdomain: 'traceroute', name: 'OpenVibeTraceroute', icon: 'fa-route', desc: 'Traceroute — visualize network path & hops', status: 'unavailable', unavailable: 'Traceroute needs raw network access that this server does not give the tools. The TCP ping test shows reachability and latency.' },
++ { id: 'mtr', subdomain: 'mtr', name: 'OpenVibeMTR', icon: 'fa-chart-line', desc: 'MTR — combined ping + traceroute analysis', status: 'unavailable', unavailable: 'MTR needs raw network access that this server does not give the tools. The TCP ping test shows latency and loss to the host.' },
++ { id: 'port', subdomain: 'port', name: 'OpenVibePortCheck', icon: 'fa-door-open', desc: 'Port scanner — check if ports are open', endpoint: '/port' },
++ { id: 'headers', subdomain: 'headers', name: 'OpenVibeHeaders', icon: 'fa-list', desc: 'HTTP headers checker — security & cache analysis', endpoint: '/headers' },
++ { id: 'redirects', subdomain: 'redirects', name: 'OpenVibeRedirects', icon: 'fa-share', desc: 'Redirect chain tracer — follow all hops', endpoint: '/redirects' },
++ { id: 'ssl', subdomain: 'ssl', name: 'OpenVibeSSL', icon: 'fa-lock', desc: 'SSL/TLS certificate checker & chain inspector', endpoint: '/ssl' },
++ { id: 'curl', subdomain: 'curl', name: 'OpenVibeCurl', icon: 'fa-download', desc: 'Online curl — HTTP request tester', endpoint: '/headers' },
++ { id: 'httpstatus', subdomain: 'httpstatus', name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check', desc: 'HTTP status code reference & checker', endpoint: '/headers' },
++ { id: 'latency', subdomain: 'latency', name: 'OpenVibeLatency', icon: 'fa-gauge-high', desc: 'Latency tester — measure response times', endpoint: '/ping' },
// Reputation / mail / infra
-- { id: 'blacklist', subdomain: 'blacklist', name: 'OpenVibeBlacklist', icon: 'fa-ban', desc: 'Blacklist / RBL check for IP & domain' },
-- { id: 'reputation', subdomain: 'reputation', name: 'OpenVibeReputation', icon: 'fa-star', desc: 'Domain & IP reputation score' },
-- { id: 'smtp', subdomain: 'smtp', name: 'OpenVibeSMTP', icon: 'fa-paper-plane', desc: 'SMTP server connectivity tester' },
-- { id: 'uptime', subdomain: 'uptime', name: 'OpenVibeUptime', icon: 'fa-heart-pulse', desc: 'Uptime & availability monitor' },
-- { id: 'robots', subdomain: 'robots', name: 'OpenVibeRobots', icon: 'fa-robot', desc: 'robots.txt analyzer' },
-- { id: 'sitemap', subdomain: 'sitemap', name: 'OpenVibeSitemap', icon: 'fa-sitemap', desc: 'Sitemap.xml validator' },
++ { id: 'blacklist', subdomain: 'blacklist', name: 'OpenVibeBlacklist', icon: 'fa-ban', desc: 'Blacklist / RBL check for IP & domain', endpoint: '/blacklist' },
++ { id: 'reputation', subdomain: 'reputation', name: 'OpenVibeReputation', icon: 'fa-star', desc: 'Domain & IP reputation score', status: 'unavailable', unavailable: 'A combined reputation report is not available yet. The blacklist, SPF, DKIM and DMARC checkers cover its parts.' },
++ { id: 'smtp', subdomain: 'smtp', name: 'OpenVibeSMTP', icon: 'fa-paper-plane', desc: 'SMTP server connectivity tester', endpoint: '/smtp' },
++ { id: 'uptime', subdomain: 'uptime', name: 'OpenVibeUptime', icon: 'fa-heart-pulse', desc: 'Uptime & availability monitor', endpoint: '/uptime' },
++ { id: 'robots', subdomain: 'robots', name: 'OpenVibeRobots', icon: 'fa-robot', desc: 'robots.txt analyzer', endpoint: '/robots' },
++ { id: 'sitemap', subdomain: 'sitemap', name: 'OpenVibeSitemap', icon: 'fa-sitemap', desc: 'Sitemap.xml validator', endpoint: '/sitemap' },
];
// Quick lookup: subdomain → tool definition
ddiff --git a/apps/gateway/server/net/routes.js b/apps/gateway/server/net/routes.js
iindex de8568b..f641e69 100644
---- a/apps/gateway/server/net/routes.js
++++ b/apps/gateway/server/net/routes.js
@@@ -14,6 +14,11 @@ const { URL } = require('url');
const net = require('net');
const { getNetConfig, NET_TOOLS } = require('./config');
const { createEgress, TargetRefused } = require('../../../_shared/egress');
++const { createCache } = require('./cache');
++const { createChecks } = require('./checks');
++
++const HOUR = 60 * 60_000;
++const IPAPI_FIELDS = 'status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,asname,reverse,query';
/**
* Every tool here that connects to the target (ssl, headers, redirects, port, ping, lookup, and a
@@@ -23,6 +28,11 @@ const { createEgress, TargetRefused } = require('../../../_shared/egress');
module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const router = express.Router();
const egress = opts.egress || createEgress();
++ // Upstream answers are cached: ip-api's free tier is rate limited (and plain HTTP), RDAP and DoH
++ // answers change slowly. Tests pass their own fetch.
++ const fetchImpl = opts.fetch || fetch;
++ const cache = opts.cache || createCache({ max: 5000, ttlMs: HOUR });
++ const checks = createChecks({ egress, cache, ...(opts.resolverFor && { resolverFor: opts.resolverFor }), ...(opts.tlsUpgrade && { tlsUpgrade: opts.tlsUpgrade }) });
// ── Helpers ──────────────────────────────────────────────
@@@ -59,11 +69,54 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
-- const res = await fetch(url, { ...opts, signal: controller.signal });
++ const res = await fetchImpl(url, { ...opts, signal: controller.signal });
return res;
} finally { clearTimeout(timer); }
}
++ /**
++ * Geolocation + network owner of an IP, in ip-api's shape. With NET_IPINFO_TOKEN it comes from
++ * ipinfo.io over HTTPS; without, from ip-api.com's free tier (HTTP only). Cached an hour per IP.
++ */
++ function geoLookup(ip) {
++ return cache.wrap(`geo:${ip}`, async () => {
++ const c = cfg();
++ if (c.ipinfo.token) {
++ const r = await timedFetch(`${c.ipinfo.baseUrl}/${encodeURIComponent(ip)}/json?token=${encodeURIComponent(c.ipinfo.token)}`, { headers: { Accept: 'application/json' } });
++ const d = await r.json();
++ if (!r.ok || d.error) return { status: 'fail', message: (d.error && (d.error.message || d.error.title)) || `ipinfo HTTP ${r.status}` };
++ if (d.bogon) return { status: 'fail', message: 'reserved range', query: ip };
++ const [lat, lon] = String(d.loc || '').split(',').map(Number);
++ const org = d.org || '';
++ const asMatch = /^(AS\d+)\s+(.*)$/.exec(org);
++ return {
++ status: 'success', query: d.ip || ip, country: d.country, countryCode: d.country, region: d.region, regionName: d.region,
++ city: d.city, zip: d.postal, lat: Number.isFinite(lat) ? lat : undefined, lon: Number.isFinite(lon) ? lon : undefined,
++ timezone: d.timezone, isp: asMatch ? asMatch[2] : org, org: asMatch ? asMatch[2] : org, as: org, asname: asMatch ? asMatch[2] : '', reverse: d.hostname || '',
++ };
++ }
++ const r = await timedFetch(`${c.ipapi.baseUrl}/json/${encodeURIComponent(ip)}?fields=${IPAPI_FIELDS}`);
++ return r.json();
++ }, (d) => (d && d.status === 'success' ? HOUR : 0));
++ }
++
++ /** RDAP for a domain or an IP. → { ok, status, data }. 200s are kept an hour, 404s ten minutes. */
++ function rdapLookup(type, target) {
++ return cache.wrap(`rdap:${type}:${target}`, async () => {
++ const r = await timedFetch(`${cfg().rdap.baseUrl}/${type}/${encodeURIComponent(target)}`, { headers: { Accept: 'application/rdap+json, application/json' } });
++ return { ok: r.ok, status: r.status, data: r.ok ? await r.json() : null };
++ }, (v) => (v.ok ? HOUR : v.status === 404 ? 10 * 60_000 : 0));
++ }
++
++ /** DNS-over-HTTPS (Google JSON API) for one name and type. Kept for the answers' TTL (30 s – 5 min). */
++ function dohQuery(name, type) {
++ return cache.wrap(`doh:${type}:${name}`, async () => {
++ const r = await timedFetch(`${cfg().doh.google}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`, { headers: { Accept: 'application/dns-json' } });
++ const data = await r.json();
++ return (data.Answer || []).map(a => ({ name: a.name, type: a.type, TTL: a.TTL, data: a.data }));
++ }, (answers) => Math.min(300, Math.max(30, ...answers.map(a => a.TTL || 0))) * 1000);
++ }
++
function ok(res, data) { return res.json({ ok: true, ...data }); }
function fail(res, msg, status = 400, code) { return res.status(status).json({ ok: false, error: msg, ...(code ? { code } : {}) }); }
@@@ -112,11 +165,11 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
// IP Info / GeoIP / ISP / ASN / MyIP
// ═══════════════════════════════════════════════════════════
++ // The address Express resolved through `trust proxy` (one nginx hop, which sets X-Forwarded-For
++ // from $remote_addr). The first X-Forwarded-For entry is whatever the client sent: never used.
router.get('/myip', (req, res) => {
-- const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
-- || req.headers['x-real-ip']
-- || req.socket.remoteAddress || '';
-- ok(res, { ip });
++ const ip = String(req.ip || req.socket.remoteAddress || '').replace(/^::ffff:(?=\d+\.)/, '');
++ ok(res, { ip, version: net.isIP(ip) || null });
});
router.get('/ip/:target?', async (req, res) => {
@@@ -137,11 +190,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
hostname = target;
}
-- // GeoIP via ip-api.com (free, no key, JSON)
-- const c = cfg();
-- const ipUrl = `${c.ipapi.baseUrl}/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,asname,reverse,query`;
-- const r = await timedFetch(ipUrl);
-- const data = await r.json();
++ // GeoIP (ipinfo with a token, else ip-api.com; cached)
++ const data = await geoLookup(ip);
if (data.status === 'fail') return fail(res, data.message || 'Lookup failed');
@@@ -204,11 +254,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const isLoopback = ip.startsWith('127.');
const isLinkLocal = ip.startsWith('169.254.');
-- // GeoIP via ip-api.com
-- const c = cfg();
-- const ipUrl = `${c.ipapi.baseUrl}/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,asname,reverse,query`;
-- const r = await timedFetch(ipUrl);
-- const data = await r.json();
++ // GeoIP (ipinfo with a token, else ip-api.com; cached)
++ const data = await geoLookup(ip);
if (data.status === 'fail') return fail(res, data.message || 'Lookup failed');
@@@ -316,13 +363,7 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
for (const type of validTypes) {
try {
if (useDoh) {
-- const r = await timedFetch(`${c.doh.google}?name=${encodeURIComponent(target)}&type=${type}`, {
-- headers: { Accept: 'application/dns-json' },
-- });
-- const data = await r.json();
-- results[type] = (data.Answer || []).map(a => ({
-- name: a.name, type: a.type, TTL: a.TTL, data: a.data,
-- }));
++ results[type] = await dohQuery(target, type);
} else {
const resolver = new dns.Resolver();
if (server) resolver.setServers([server]);
@@@ -336,11 +377,7 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
results[type] = await resolver[method](target);
} else {
// Fallback to DoH for unsupported types
-- const r = await timedFetch(`${c.doh.google}?name=${encodeURIComponent(target)}&type=${type}`, {
-- headers: { Accept: 'application/dns-json' },
-- });
-- const data = await r.json();
-- results[type] = (data.Answer || []).map(a => ({ name: a.name, type: a.type, TTL: a.TTL, data: a.data }));
++ results[type] = await dohQuery(target, type);
}
}
} catch (err) {
@@@ -390,15 +427,12 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
try {
const target = parseTarget(req.params.target || req.query.target);
if (!target) return fail(res, 'Please provide a domain or IP');
-- const c = cfg();
--
-- // RDAP queries: domain → /domain/, IP → /ip/
++ // RDAP queries: domain → /domain/, IP → /ip/ (cached)
const rdapType = isIP(target) ? 'ip' : 'domain';
-- const url = `${c.rdap.baseUrl}/${rdapType}/${encodeURIComponent(target)}`;
-- const r = await timedFetch(url, { headers: { Accept: 'application/rdap+json, application/json' } });
++ const r = await rdapLookup(rdapType, target);
if (!r.ok) return fail(res, `RDAP lookup failed (${r.status})`, r.status >= 500 ? 502 : 404);
-- const data = await r.json();
++ const data = r.data;
// Extract key fields from RDAP response
const summary = {
@@@ -690,9 +724,7 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const addrs = await dns.resolve4(target).catch(() => []);
if (addrs.length) ip = addrs[0]; else return null;
}
-- const c = cfg();
-- const r = await timedFetch(`${c.ipapi.baseUrl}/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,isp,org,as,asname,reverse,query`);
-- const data = await r.json();
++ const data = await geoLookup(ip);
if (data.status === 'fail') return null;
return data;
})(),
@@@ -711,13 +743,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
})(),
// RDAP
(async () => {
-- const c = cfg();
-- const rdapType = isIP(target) ? 'ip' : 'domain';
-- const r = await timedFetch(`${c.rdap.baseUrl}/${rdapType}/${encodeURIComponent(target)}`, {
-- headers: { Accept: 'application/rdap+json, application/json' },
-- });
-- if (!r.ok) return null;
-- return await r.json();
++ const r = await rdapLookup(isIP(target) ? 'ip' : 'domain', target);
++ return r.ok ? r.data : null;
})(),
// SSL (domain only)
(async () => {
@@@ -816,5 +843,22 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
}
});
++ // ═══════════════════════════════════════════════════════════
++ // Website, mail and DNS checks (server/net/checks.js)
++ // ═══════════════════════════════════════════════════════════
++
++ const raw = (req) => String(req.params.target || req.query.target || '').trim();
++ const host = (req) => { const t = parseTarget(raw(req)); if (!t) throw Object.assign(new Error('Please provide a domain or IP'), { status: 400 }); return t; };
++ const run = (label, fn) => async (req, res) => {
++ try { ok(res, await fn(req)); } catch (err) { failFrom(res, err, `${label} failed`); }
++ };
++
++ router.get('/robots/:target?', run('robots.txt check', (req) => checks.robots(raw(req), { path: req.query.path, ua: req.query.ua })));
++ router.get('/sitemap/:target?', run('Sitemap check', (req) => checks.sitemap(raw(req))));
++ router.get('/uptime/:target?', run('Uptime check', (req) => checks.uptime(raw(req))));
++ router.get('/smtp/:target?', run('SMTP test', (req) => checks.smtp(host(req), { port: req.query.port })));
++ router.get('/blacklist/:target?', run('Blacklist check', (req) => checks.blacklist(host(req))));
++ router.get('/dnsprop/:target?', run('DNS propagation check', (req) => checks.dnsprop(host(req), { type: req.query.type })));
++
return router;
};
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex 5c32899..da2f2d6 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -195,7 +195,7 @@ function pageTool(t) {
const hosts = [...(t.hosts.short ? [[t.hosts.short, 'Easiest to remember']] : []), [t.hosts.canonical, t.hosts.short ? 'Search-friendly address' : 'Primary'], ...(t.hosts.mirrors || []).map(a => [a, 'Mirror']), ...t.hosts.aliases.map(a => [a, 'Redirects here'])];
const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${f && f.path ? `<a href="${esc(f.path)}">${esc(f.name)}</a> › ` : ''}${esc(t.name)}</nav>
<div class="page-h">${icon(t.icon, 56)}<h1>${esc(t.name)}</h1></div><p class="lead">${esc(t.description)}</p>
--<a class="cta" href="${esc(t.url)}">Open ${esc(t.name)}</a>
++${t.status === 'unavailable' ? `<p class="lead"><strong>Not available yet.</strong> ${esc(t.unavailable || '')}</p>` : `<a class="cta" href="${esc(t.url)}">Open ${esc(t.name)}</a>`}
<section><div class="sec-h"><h2>Where to find it</h2></div><p class="sec-p">Every address below opens the same tool. The first address is the one to bookmark and share.</p>
<ul class="hosts">${hosts.map(([h, tag], i) => `<li${i === 0 ? ' class="primary"' : ''}><a href="https://${esc(h)}/"${i === 0 ? '' : ' rel="nofollow"'}>${esc(h)}</a><span class="tag">${tag}</span></li>`).join('')}</ul></section>
${t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div><ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : ''}
ddiff --git a/apps/gateway/server/registry/families.js b/apps/gateway/server/registry/families.js
iindex 05b4eb4..163e54e 100644
---- a/apps/gateway/server/registry/families.js
++++ b/apps/gateway/server/registry/families.js
@@@ -13,11 +13,11 @@ const FAMILIES = [
{
id: 'net', name: 'Network Tools', icon: 'network', path: '/network-tools', hub: 'net', hubAliases: ['network'], port: null,
tagline: 'Look up DNS, IPs, domains, certificates and mail servers',
-- description: 'Diagnostics for anyone who runs a website, a mail server or a home network. Look up DNS records, find out who owns an IP address or a domain, check an SSL certificate, trace a route, test a port and see whether a site is reachable from other parts of the world.',
++ description: 'Diagnostics for anyone who runs a website, a mail server or a home network. Look up DNS records, find out who owns an IP address or a domain, check an SSL certificate, test a port or a mail server, check blacklists and see whether a site is up from outside your own network.',
intro: 'These tools answer the questions that come up when something on the internet does not work: where does this domain point, why is mail bouncing, is the certificate about to expire, is the site down for everyone. Each one takes a domain, an IP address or a URL and gives a readable answer in a few seconds.',
faq: [
['Do I need to install anything?', 'No. Every lookup runs from our servers and the result appears in your browser, so the tools work on a phone, a locked-down work laptop or a Chromebook.'],
-- ['Where do the lookups run from?', 'DNS, WHOIS and certificate checks run from the OpenVibe servers. Ping, traceroute and latency tests can run from several regions so you can compare results around the world.'],
++ ['Where do the lookups run from?', 'Every check runs from the OpenVibe server, outside your own network. Ping and latency are timed TCP connections; traceroute and MTR are not available because they need raw network access the server does not give the tools.'],
['Can I check a server inside my own network?', 'No. The checks come from the public internet, so the target has to be reachable from outside your network.'],
],
},
ddiff --git a/apps/gateway/server/registry/index.js b/apps/gateway/server/registry/index.js
iindex a4af64f..8d1e7dd 100644
---- a/apps/gateway/server/registry/index.js
++++ b/apps/gateway/server/registry/index.js
@@@ -13,6 +13,7 @@
const { FAMILIES } = require('./families');
const { SATELLITE_COPY } = require('./copy-satellites');
const { CATALOG: SEO } = require('../seo/catalog');
++const { NET_TOOL_MAP } = require('../net/config');
const { createServiceDirectory } = require('./services');
const ZONE = 'openvibe.tools';
@@@ -56,10 +57,13 @@ function baseTools() {
const out = [];
for (const rec of SEO.values()) {
if (rec.hub) continue;
++ // A net tool without an implementation says so (net/config.js status 'unavailable').
++ const net = rec.family === 'net' ? NET_TOOL_MAP.get(rec.sub) : null;
out.push({
id: rec.sub, family: rec.family, name: rec.name, tagline: firstSentence(rec.desc), description: rec.about || rec.desc,
keywords: String(rec.kw || '').split(',').map(k => k.trim()).filter(Boolean),
icon: (rec.family === 'net' && NET_ICONS[rec.sub]) || (rec.sub.includes('json') ? 'json' : FAMILY_ICON[rec.family]) || 'tools',
++ ...(net && net.status === 'unavailable' && { status: 'unavailable', unavailable: net.unavailable }),
});
}
for (const t of SATELLITE_COPY) out.push({ ...t });
@@@ -170,7 +174,7 @@ function catalog() {
services: services.snapshot(),
families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),
planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),
-- tools: r.tools.map(t => ({ id: t.id, family: t.family, alsoIn: t.alsoIn, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
++ tools: r.tools.map(t => ({ id: t.id, family: t.family, alsoIn: t.alsoIn, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}`, status: t.status || 'available', ...(t.unavailable && { unavailable: t.unavailable }) })),
};
}
ddiff --git a/apps/gateway/server/seo/catalog.js b/apps/gateway/server/seo/catalog.js
iindex f60978b..5255930 100644
---- a/apps/gateway/server/seo/catalog.js
++++ b/apps/gateway/server/seo/catalog.js
@@@ -340,10 +340,10 @@ const DEV = {
const NET = {
net: {
name: 'Network Tools', title: 'Online Network Tools — Ping, DNS, Whois & SSL Diagnostics',
-- desc: 'Run ping, traceroute, DNS lookups, Whois, SSL checks, port scans and HTTP header tests from the browser. Network diagnostics with no signup.',
++ desc: 'Run DNS lookups, Whois, SSL checks, TCP ping, port checks, blacklist and SMTP tests and HTTP header checks from the browser. Network diagnostics with no signup.',
kw: 'network tools, online network diagnostics, ping test, dns lookup, whois, ssl checker',
about: 'A set of network diagnostics that run from our servers so you can test a host from outside your own connection — useful when you need to know whether a problem is yours or theirs.',
-- bullets: ['Tests run from our infrastructure, not your browser', 'Covers DNS, email deliverability, HTTP, TLS and routing', 'No account required'],
++ bullets: ['Tests run from our server, not your browser', 'Covers DNS, email deliverability, HTTP and TLS', 'No account required'],
faq: [['Why test from a server instead of my machine?', 'Testing from outside your network rules out your own DNS cache, firewall or ISP, which is exactly what you need when a site works for you but not for someone else.'], ['Do I need an account?', 'No. There are reasonable rate limits to keep them available for everyone.']],
},
lookup: { name: 'Domain & IP Lookup', title: 'Domain & IP Lookup — All-in-One Online Lookup Tool', desc: 'Look up everything about a domain or IP in one go: DNS records, Whois, geolocation, SSL and HTTP headers. Instant, no signup.', kw: 'domain lookup, ip lookup, whois lookup, dns lookup, all in one lookup tool', about: 'Enter a domain or IP address and get a complete report in one pass: registration details, DNS records, hosting location, certificate status and HTTP response headers.', bullets: ['One query returns DNS, Whois, geolocation and TLS detail', 'Works with domains, IPv4 and IPv6 addresses', 'A fast first step when diagnosing any site problem'], faq: [['What can I learn from a domain lookup?', 'Who registered the domain and when it expires, which nameservers and mail servers it uses, where it is hosted, and whether its certificate is valid.'], ['Does this work for IP addresses too?', 'Yes. Enter an IP to see its owner, network range, geolocation and reverse DNS name.']] },
@@@ -361,29 +361,29 @@ const NET = {
dns: { name: 'DNS Lookup', title: 'DNS Lookup — Online DNS Record Checker (A, MX, TXT)', desc: 'Look up DNS records for any domain: A, AAAA, MX, TXT, CNAME, NS, SOA and more. DNS lookup with instant results.', kw: 'dns lookup, dns record checker, check dns records, a record lookup, mx lookup, txt record', about: 'Query any DNS record type for a domain and see the answers exactly as a resolver receives them, including TTL values so you know how long a change will take to spread.', bullets: ['All common record types: A, AAAA, MX, TXT, CNAME, NS, SOA', 'TTL values shown so you can predict propagation', 'Queried from our resolvers, bypassing your local cache'], faq: [['Why do I still see the old DNS record?', 'Resolvers cache answers for the length of the TTL. This tool queries directly, so it usually shows the new value before your machine does.'], ['Which record do I need for a website?', 'An A record (or AAAA for IPv6) pointing at your server\'s IP, or a CNAME pointing at another hostname.']] },
dig: { name: 'Online dig', title: 'Online dig Command — Web-Based DNS Query Tool', desc: 'Run dig from your browser to query any DNS record type and read the full answer, authority and additional sections. No install.', kw: 'online dig, dig command online, dns query tool, dig web interface, dns debugging', about: 'The dig command without a terminal: choose a record type, query any domain, and read the complete response including the authority and additional sections.', bullets: ['Full response sections, not just the answer', 'Any record type, including less common ones', 'No local install — useful from a locked-down machine'], faq: [['What is the difference between dig and nslookup?', 'dig shows the complete DNS response including flags and section detail, which makes it the better tool for debugging. nslookup is simpler and shows just the answer.'], ['Can I query a specific nameserver?', 'Yes — that is the usual way to confirm an authoritative server has your change before caches expire.']] },
nslookup: { name: 'NSLookup', title: 'NSLookup Online — Web-Based DNS Lookup Tool', desc: 'Run nslookup from your browser to resolve domains and check DNS records. Simple, fast and no install required.', kw: 'nslookup online, nslookup tool, dns lookup command, resolve domain online', about: 'A simple DNS query in the familiar nslookup style: enter a domain, get the addresses and records it resolves to.', bullets: ['Straightforward answers without protocol noise', 'Common record types covered', 'Runs from our resolvers, so it ignores your local cache'], faq: [['Why does nslookup show a different answer than my browser?', 'Your browser and operating system cache DNS answers. A direct query shows the current published record.'], ['Is nslookup still worth using?', 'For a quick "what does this resolve to" check, yes. For debugging, dig gives far more detail.']] },
-- dnspropagation: { name: 'DNS Propagation Checker', title: 'DNS Propagation Checker — Test DNS Worldwide', desc: 'Check whether a DNS change has propagated by querying resolvers around the world and comparing the answers. Instant.', kw: 'dns propagation checker, dns propagation test, global dns check, dns worldwide, check dns update', about: 'Query the same record from resolvers in different regions to see which ones have picked up your change and which are still serving the cached answer.', bullets: ['Answers from multiple global regions side by side', 'Highlights resolvers still serving the old value', 'Works for any record type, not just A records'], faq: [['How long does DNS propagation take?', 'Up to the record\'s previous TTL — commonly an hour, sometimes 24 or 48 hours if the TTL was set high. Lowering the TTL before a planned change makes the next one faster.'], ['Why do some regions still show the old IP?', 'Those resolvers cached the previous answer and will keep serving it until that cache entry expires.']] },
++ dnspropagation: { name: 'DNS Propagation Checker', title: 'DNS Propagation Checker — Compare Public DNS Resolvers', desc: 'Check whether a DNS change has propagated by asking ten public resolvers (Google, Cloudflare, Quad9, OpenDNS and more) and comparing their answers. Instant.', kw: 'dns propagation checker, dns propagation test, global dns check, dns worldwide, check dns update', about: 'Ask the same question of ten large public DNS resolvers run by different operators and see their answers side by side, so you can tell which ones have picked up your change and which still serve a cached value. These resolvers are anycast networks: each answer comes from the operator\'s node nearest to our server.', bullets: ['Answers from ten public resolvers side by side', 'Highlights resolvers that disagree with the majority', 'A, AAAA, CNAME, MX, NS and TXT records'], faq: [['How long does DNS propagation take?', 'Up to the record\'s previous TTL — commonly an hour, sometimes 24 or 48 hours if the TTL was set high. Lowering the TTL before a planned change makes the next one faster.'], ['Why do some resolvers still show the old IP?', 'Those resolvers cached the previous answer and will keep serving it until that cache entry expires.']] },
mx: { name: 'MX Record Lookup', title: 'MX Record Lookup — Find Mail Servers for Any Domain', desc: 'Look up the MX records for a domain to see which mail servers handle its email and in what priority order. Instant.', kw: 'mx record lookup, mail server lookup, mx check, email dns records, find mail server', about: 'See which servers accept mail for a domain and their priority order — the first thing to check when email is bouncing or arriving somewhere unexpected.', bullets: ['All MX records with priority values', 'Resolves each mail server to its address', 'Flags domains with no MX record at all'], faq: [['What does the MX priority number mean?', 'Lower numbers are tried first. Equal values are used in rotation, which spreads load across servers.'], ['Why is my email bouncing?', 'Start here: a missing MX record, or one pointing at a host that no longer exists, stops delivery outright.']] },
txt: { name: 'TXT Record Lookup', title: 'TXT Record Lookup — Check SPF, DKIM & Verification Records', desc: 'Look up TXT records for any domain, including SPF, DKIM, DMARC and site verification strings. Instant DNS TXT checker.', kw: 'txt record lookup, spf record check, dns txt records, domain verification record, check txt record', about: 'Read every TXT record published for a domain — the place where SPF policies, domain verification tokens and assorted service strings all live.', bullets: ['Every TXT record, shown in full', 'Highlights SPF, DKIM and verification entries', 'Useful for confirming a verification token is live'], faq: [['Why is my domain verification failing?', 'Usually the record was added to the wrong host name, or the provider wrapped it in extra quotes. This shows exactly what is published.'], ['Can a domain have more than one TXT record?', 'Yes, and most do. The exception is SPF: exactly one SPF record is allowed, and multiple ones cause failures.']] },
ns: { name: 'Nameserver Lookup', title: 'NS Record Lookup — Find the Nameservers for Any Domain', desc: 'Look up the NS records for a domain to see which nameservers are authoritative for it. Instant nameserver checker.', kw: 'ns record lookup, nameserver lookup, find nameservers, authoritative dns, dns delegation check', about: 'Find which nameservers a domain is delegated to, and confirm the delegation at the registry matches what the nameservers themselves claim.', bullets: ['Authoritative nameservers for the domain', 'Reveals mismatches between registry and zone', 'First check when a DNS change has no effect'], faq: [['Why do my DNS changes have no effect?', 'Often the domain is delegated to different nameservers than the ones you edited. This shows which servers are actually authoritative.'], ['How many nameservers should a domain have?', 'At least two, on separate networks, so the domain still resolves if one goes down.']] },
spf: { name: 'SPF Record Checker', title: 'SPF Record Checker — Validate Your Email SPF Policy', desc: 'Check and validate a domain\'s SPF record. See which servers may send mail, catch syntax errors and count DNS lookups. Instant.', kw: 'spf record checker, spf validator, check spf, email authentication, spf lookup limit', about: 'Read a domain\'s SPF policy, expand every include, and confirm it stays inside the ten-lookup limit that causes silent failures when exceeded.', bullets: ['Full expansion of includes and redirects', 'Counts DNS lookups against the limit of ten', 'Flags syntax errors and multiple SPF records'], faq: [['What happens if SPF exceeds ten DNS lookups?', 'Receiving servers return a permanent error and may treat your mail as unauthenticated, so legitimate messages can land in spam.'], ['Can I have two SPF records?', 'No. A domain must publish exactly one; two records cause validation to fail entirely.']] },
dkim: { name: 'DKIM Record Lookup', title: 'DKIM Record Lookup & Validator — Email Auth Checker', desc: 'Look up and validate DKIM records by selector. Confirm your signing key is published correctly for email authentication. Instant.', kw: 'dkim record lookup, dkim checker, dkim validator, dkim selector, email authentication', about: 'Fetch the DKIM public key for a given selector and confirm it is published and well-formed, so signed mail can be verified by receiving servers.', bullets: ['Selector-based lookup for any domain', 'Validates key format and flags', 'Confirms the record is reachable before you go live'], faq: [['What is a DKIM selector?', 'A label that identifies which key to use, published at selector._domainkey.yourdomain.com. Your mail provider tells you which selector it signs with.'], ['Why is DKIM failing after a provider change?', 'The new provider signs with a different selector and key, so the old record no longer matches. Publish the new one they supply.']] },
dmarc: { name: 'DMARC Checker', title: 'DMARC Record Checker — Validate Your DMARC Policy', desc: 'Check a domain\'s DMARC record and policy. See enforcement level, reporting addresses and alignment settings. Instant.', kw: 'dmarc checker, dmarc record lookup, dmarc validator, dmarc policy, email security check', about: 'Read a domain\'s DMARC policy: whether failures are monitored, quarantined or rejected, where reports are sent, and how strictly alignment is enforced.', bullets: ['Policy level, percentage and alignment settings', 'Reporting addresses for aggregate and forensic reports', 'Syntax validation with clear explanations'], faq: [['What DMARC policy should I start with?', 'Start at p=none with a reporting address. That monitors without affecting delivery, so you can find legitimate senders before moving to quarantine and then reject.'], ['Do I need SPF and DKIM for DMARC to work?', 'Yes. DMARC builds on both, and requires that at least one passes and aligns with the visible From domain.']] },
-- ping: { name: 'Ping Test', title: 'Online Ping Test — Check if a Host Is Up', desc: 'Ping any host or IP from multiple global regions to check reachability and latency. Online ping test with no install.', kw: 'ping test, online ping, ping ip, check if server is up, latency test, ping tool', about: 'Send ICMP pings from our servers to check whether a host answers and how long the round trip takes, from several regions at once.', bullets: ['Results from multiple global regions', 'Round-trip time, jitter and packet loss', 'Confirms whether an outage is local to you or global'], faq: [['Why does a site load but not respond to ping?', 'Many hosts and firewalls block ICMP on purpose. A failed ping does not always mean a site is down — check HTTP headers instead.'], ['What is a good ping time?', 'Under 50 ms feels instant, under 100 ms is fine for most uses, and over 200 ms is noticeable in calls and games.']] },
-- traceroute: { name: 'Traceroute', title: 'Online Traceroute — Trace the Network Path to Any Host', desc: 'Run a traceroute from our servers to see every hop between us and a host, with latency per hop. No install required.', kw: 'traceroute online, trace route, network path, tracert online, hop by hop latency', about: 'Map the route packets take to a destination, hop by hop, with timing at each step so you can see where latency is introduced or where the path stops.', bullets: ['Every hop with per-hop latency', 'Reverse DNS names to identify the networks in the path', 'Shows exactly where a broken path terminates'], faq: [['Why do some hops show stars?', 'Those routers are configured not to reply to probes. It is normal and does not mean the path is broken.'], ['How do I read a traceroute?', 'Look for the point where latency jumps sharply or replies stop. The hop before it usually names the network where the trouble starts.']] },
-- mtr: { name: 'MTR Report', title: 'Online MTR — Combined Ping & Traceroute Report', desc: 'Run MTR to combine traceroute and ping into one report with per-hop packet loss and latency over time. Network path analysis.', kw: 'mtr online, mtr report, my traceroute, packet loss test, network path analysis', about: 'MTR repeats a traceroute continuously and aggregates the results, so intermittent packet loss at a single hop shows up clearly instead of hiding behind one lucky probe.', bullets: ['Per-hop loss percentage over many probes', 'Best, average and worst latency for each hop', 'The report network operators ask for in support tickets'], faq: [['Why is MTR better than traceroute for support tickets?', 'It samples repeatedly, so it distinguishes a genuinely lossy hop from a single dropped probe. That is the evidence a network operator needs.'], ['Loss shows at one hop but not later ones — is that a problem?', 'No. That router is just rate-limiting its own replies. Real trouble shows as loss that continues at every hop after it.']] },
++ ping: { name: 'Ping Test', title: 'Online Ping Test (TCP) — Check if a Host Is Up', desc: 'Check whether a host answers and how fast, by timing TCP connections to port 443 from our server. Min, average, max and loss. No install.', kw: 'ping test, online ping, ping ip, check if server is up, latency test, ping tool, tcp ping', about: 'Our server cannot send ICMP pings without raw network access, so this test times TCP connections instead: it opens a connection to the host on port 443 (HTTPS) several times from the OpenVibe server and reports how long each took and how many failed.', bullets: ['Connect time for each attempt, with min, average and max', 'Loss: attempts that were refused or timed out', 'Runs from the OpenVibe server (one location)'], faq: [['Why a TCP ping instead of ICMP?', 'Sending ICMP needs raw network access that our tools do not have. A timed TCP connection answers the same question — is the host up, how far away is it — and passes firewalls that drop ICMP.'], ['What is a good ping time?', 'Under 50 ms feels instant, under 100 ms is fine for most uses, and over 200 ms is noticeable in calls and games.']] },
++ traceroute: { name: 'Traceroute', title: 'Online Traceroute — Not Available Yet', desc: 'Traceroute is not available yet: it needs raw network access that our server does not give the tools. The TCP ping test shows whether a host answers and how fast.', kw: 'traceroute online, trace route, network path, tracert online, hop by hop latency', about: 'A traceroute sends probes with a rising TTL and listens for the routers that drop them, which needs raw network access our server does not give the tools. Until that changes this tool is not available; the TCP ping test tells you whether the host is reachable and how long a connection takes.', bullets: ['Not available yet', 'Use the TCP ping test for reachability and latency', 'Run traceroute (Windows: tracert) on your own machine for the hop-by-hop path'], faq: [['Why is there no traceroute?', 'Tracing a route needs raw sockets, a privilege our tool servers do not have. We would rather say so than show a ping result under a traceroute heading.'], ['How do I read a traceroute?', 'Look for the point where latency jumps sharply or replies stop. The hop before it usually names the network where the trouble starts.']] },
++ mtr: { name: 'MTR Report', title: 'Online MTR Report — Not Available Yet', desc: 'MTR is not available yet: like traceroute it needs raw network access that our server does not give the tools. The TCP ping test shows reachability, latency and loss.', kw: 'mtr online, mtr report, my traceroute, packet loss test, network path analysis', about: 'MTR repeats a traceroute continuously and aggregates the results per hop. It needs the same raw network access as traceroute, which our server does not give the tools, so it is not available yet. The TCP ping test reports latency and loss to the host itself.', bullets: ['Not available yet', 'Use the TCP ping test for latency and loss to the host', 'Run mtr on your own machine for a per-hop report'], faq: [['Why is MTR better than traceroute for support tickets?', 'It samples repeatedly, so it distinguishes a genuinely lossy hop from a single dropped probe. That is the evidence a network operator needs.'], ['Why is it not available here?', 'It needs raw sockets, a privilege our tool servers do not have.']] },
port: { name: 'Port Checker', title: 'Open Port Checker — Test if a Port Is Open', desc: 'Check whether a TCP port is open on any host, from outside your network. Test common services or any custom port. Instant.', kw: 'port checker, open port test, port scanner online, check port open, tcp port test', about: 'Test whether a port accepts connections from the public internet — the definitive check on whether a port forward, firewall rule or service binding actually works.', bullets: ['Tests from outside your network, as a real client would', 'Common service ports or any custom port', 'Distinguishes closed ports from filtered ones'], faq: [['My service runs but the port shows closed — why?', 'Either a firewall is dropping the traffic, the port forward is missing, or the service is bound to localhost instead of all interfaces.'], ['What is the difference between closed and filtered?', 'Closed means the host actively refused the connection. Filtered means nothing answered at all, which usually indicates a firewall dropping packets silently.']] },
headers: { name: 'HTTP Header Checker', title: 'HTTP Header Checker — Inspect Response Headers Online', desc: 'Check the HTTP response headers of any URL, including security, caching and compression headers. Header analysis tool.', kw: 'http header checker, response headers, security headers check, cache headers, view http headers', about: 'Fetch any URL and read the full response headers, with the security and caching headers called out so you can see what a browser is being told to do.', bullets: ['Complete response headers exactly as sent', 'Security header review including HSTS and CSP', 'Cache and compression headers explained'], faq: [['Which security headers should I have?', 'At minimum Strict-Transport-Security, X-Content-Type-Options and a Content-Security-Policy. The report lists what is present and what is missing.'], ['Why is my page not being cached?', 'Look at Cache-Control. A no-store or no-cache directive, or a missing max-age, prevents browsers and CDNs from reusing the response.']] },
redirects: { name: 'Redirect Checker', title: 'Redirect Checker — Trace Redirect Chains Online', desc: 'Follow every hop in a redirect chain and see each status code and destination. Find redirect loops and SEO-damaging chains. Instant.', kw: 'redirect checker, redirect chain, 301 checker, url redirect tracer, redirect loop finder', about: 'Follow a URL through every redirect to its final destination, with the status code at each step, so you can spot long chains and loops that waste crawl budget and lose link value.', bullets: ['Every hop with its status code and target', 'Detects loops and unnecessarily long chains', 'Flags temporary redirects used where permanent ones belong'], faq: [['Do redirect chains hurt SEO?', 'Yes. Each extra hop slows the page and search engines limit how many they follow. Point the first URL straight at the final destination.'], ['301 or 302 — which should I use?', 'Use 301 for permanent moves so ranking signals transfer. Use 302 only when the original URL will genuinely return.']] },
ssl: { name: 'SSL Certificate Checker', title: 'SSL Certificate Checker — Online TLS & Expiry Test', desc: 'Check any site\'s SSL certificate: expiry date, issuer, chain completeness and supported TLS versions. SSL checker, instant results.', kw: 'ssl checker, ssl certificate checker, tls check, certificate expiry, ssl chain test', about: 'Inspect a site\'s TLS certificate — who issued it, when it expires, whether the chain is complete and which protocol versions the server negotiates.', bullets: ['Expiry date with days remaining', 'Chain validation that catches missing intermediates', 'Issuer, subject and alternative names'], faq: [['My certificate works in Chrome but fails elsewhere — why?', 'Almost always a missing intermediate certificate. Browsers often cache intermediates from other sites; other clients do not. This check shows whether the chain your server sends is complete.'], ['How early should I renew?', 'Automate renewal at around 30 days remaining. Most issuers allow renewal well before expiry.']] },
curl: { name: 'Online curl', title: 'Online curl — Web-Based HTTP Request Tester', desc: 'Send HTTP requests from your browser and inspect the full response: status, headers and body. Test APIs without installing anything.', kw: 'online curl, http request tester, api tester online, send http request, rest client online', about: 'Make an HTTP request from our servers and read the complete response. Useful for testing an API from outside your network or from a machine where you cannot install tools.', bullets: ['Any method, with custom headers and body', 'Full response: status, headers and body', 'Requests originate from our servers, not your IP'], faq: [['How is this different from the curl converter?', 'This sends a real request and shows the response. The converter turns a curl command into code without sending anything.'], ['Can I test an internal service?', 'No. Requests come from our servers, so the target must be reachable from the public internet.']] },
httpstatus: { name: 'HTTP Status Codes', title: 'HTTP Status Codes — Full Reference & Online Checker', desc: 'Look up what any HTTP status code means and check the code any URL returns. Complete reference from 100 to 599. Instant.', kw: 'http status codes, status code meaning, 404 meaning, 301 vs 302, http response codes list', about: 'A full reference for HTTP status codes with plain-English explanations, plus a live check of what code any URL actually returns.', bullets: ['Every status code with a practical explanation', 'Live check of the code a URL returns', 'Notes on which codes search engines act on'], faq: [['What is the difference between 301 and 302?', '301 is permanent and transfers ranking signals to the new URL. 302 is temporary and tells search engines to keep indexing the original.'], ['Should a missing page return 404 or 410?', '404 means not found and may be retried. 410 means permanently gone and gets pages dropped from an index faster.']] },
-- latency: { name: 'Latency Test', title: 'Latency Test — Measure Response Time to Any Host', desc: 'Measure response times to any host from multiple regions and compare latency worldwide. Online latency and speed check.', kw: 'latency test, response time test, server speed test, ttfb test, network latency check', about: 'Measure how long a host takes to respond from several regions, so you can see where your site feels fast and where it drags.', bullets: ['Response times from multiple global regions', 'Connection versus first-byte timing separated', 'Useful for deciding whether a CDN would help'], faq: [['What is a good time to first byte?', 'Under 200 ms is good, under 500 ms acceptable. Beyond that, visitors notice and search engines record the page as slow.'], ['Why is latency high from one region only?', 'Your server is probably far from it with no edge cache in between. A CDN usually fixes exactly this.']] },
-- blacklist: { name: 'Blacklist Checker', title: 'IP & Domain Blacklist Checker — RBL Lookup', desc: 'Check whether an IP or domain is listed on major email blacklists and RBLs. Find out why your mail is being rejected. Instant.', kw: 'blacklist check, rbl lookup, ip blacklist checker, email blacklist, spam list check', about: 'Query the major real-time blacklists to see whether an address or domain is listed — the usual explanation when mail is being rejected or silently dropped.', bullets: ['Checks many major blacklists in one query', 'Shows which list flagged you and why', 'Includes delisting links where the operator provides them'], faq: [['How do I get removed from a blacklist?', 'Fix the cause first — an open relay, a compromised account or a bad sending pattern — then use the operator\'s delisting form. Requesting removal without fixing the cause gets you relisted.'], ['Does one listing stop all my mail?', 'Not always. Different providers consult different lists, so a single listing often causes partial rather than total failure.']] },
-- reputation: { name: 'Domain Reputation Checker', title: 'Domain & IP Reputation Checker — Online Trust Score', desc: 'Check the reputation of a domain or IP across blacklists, email authentication and security signals. Reputation report.', kw: 'domain reputation, ip reputation checker, email reputation, sender score, domain trust check', about: 'A combined view of the signals that decide whether other systems trust you: blacklist status, email authentication records, certificate health and hosting details.', bullets: ['Blacklist status combined with authentication checks', 'SPF, DKIM and DMARC presence in one report', 'Practical notes on what to fix first'], faq: [['What hurts domain reputation most?', 'Sending mail without SPF, DKIM and DMARC, appearing on blacklists, and hosting alongside abusive neighbours on a shared IP.'], ['How long does reputation take to recover?', 'Weeks of consistent, authenticated sending. There is no way to reset it instantly.']] },
-- smtp: { name: 'SMTP Server Test', title: 'SMTP Server Test — Check Mail Server Connectivity', desc: 'Test an SMTP server\'s connectivity, banner, STARTTLS support and authentication requirements. Online mail server check.', kw: 'smtp test, smtp server check, mail server test, starttls check, smtp connectivity', about: 'Connect to a mail server and report what it says: the greeting banner, the extensions it advertises, whether STARTTLS is offered and whether it accepts mail for a domain.', bullets: ['Connection test with the full server greeting', 'STARTTLS and authentication support detection', 'Checks the common submission ports'], faq: [['Which SMTP port should I use?', 'Port 587 with STARTTLS for sending mail from a client, or 465 for implicit TLS. Port 25 is for server-to-server delivery and is blocked by most consumer ISPs.'], ['What does an open relay mean?', 'A server that accepts mail for domains it does not handle. It will be blacklisted quickly, so this test flags it.']] },
-- uptime: { name: 'Website Uptime Checker', title: 'Website Uptime Checker — Is a Site Down? Online Test', desc: 'Check whether a website is up from multiple regions and see its response time and status code. "Is it down" checker.', kw: 'uptime checker, is this site down, website down checker, site availability test, server status check', about: 'Check a site from several regions at once to settle the "is it down for everyone or just me" question, with status code and response time for each.', bullets: ['Checks from multiple global regions at once', 'Status code and response time per region', 'Distinguishes a genuine outage from a local problem'], faq: [['The site works for me but not for a friend — what now?', 'Run this check. If some regions fail, it is a routing or DNS propagation issue rather than the server being down.'], ['Does this monitor continuously?', 'No, this is an on-demand check. It tells you the state right now.']] },
++ latency: { name: 'TCP Latency Test', title: 'TCP Latency Test — Connect Time to Any Host', desc: 'Measure TCP connect latency to any host from our server: how long a connection to port 443 takes, repeated ten times to show the spread. No install.', kw: 'latency test, response time test, tcp latency, connect time test, network latency check', about: 'Opens a TCP connection to the host on port 443 ten times from the OpenVibe server (one location) and reports each connect time with the minimum, average, maximum and loss. It measures the network path only, not how long the web server takes to answer.', bullets: ['Ten timed TCP connections to port 443', 'Minimum, average, maximum and loss', 'Runs from the OpenVibe server (one location)'], faq: [['Is this the same as time to first byte?', 'No. It times only the TCP connection. For how long the server takes to answer a request, use the website uptime check.'], ['Why is latency high?', 'Usually distance: the host is far from our server with no edge node in between. A CDN fixes exactly this for visitors near its nodes.']] },
++ blacklist: { name: 'Blacklist Checker', title: 'IP & Domain Blacklist Checker — RBL Lookup', desc: 'Check whether an IPv4 address or a domain is listed on major email blacklists and RBLs, with the reason and a delisting link. Find out why mail is rejected.', kw: 'blacklist check, rbl lookup, ip blacklist checker, email blacklist, spam list check', about: 'Queries well-known DNS blacklists to see whether an IPv4 address or a domain is listed — the usual explanation when mail is being rejected or silently dropped. A domain is checked on the domain lists and its address on the IP lists.', bullets: ['Seven IP blacklists and three domain lists in one query', 'Shows which list flagged you and the reason it gives', 'Delisting links for every list'], faq: [['How do I get removed from a blacklist?', 'Fix the cause first — an open relay, a compromised account or a bad sending pattern — then use the operator\'s delisting form. Requesting removal without fixing the cause gets you relisted.'], ['What does "refused the query" mean?', 'Some lists do not answer queries that come through large DNS resolvers. That is not a listing; check that list on its own website.']] },
++ reputation: { name: 'Domain Reputation Checker', title: 'Domain & IP Reputation Checker — Not Available Yet', desc: 'A combined reputation report is not available yet. The blacklist checker and the SPF, DKIM and DMARC checkers cover its parts today.', kw: 'domain reputation, ip reputation checker, email reputation, sender score, domain trust check', about: 'A single reputation score needs sources we do not have yet, so this report is not available. Its parts already work: the blacklist checker for DNSBL listings, and the SPF, DKIM and DMARC checkers for email authentication.', bullets: ['Not available yet', 'Blacklist status: the blacklist checker', 'Email authentication: the SPF, DKIM and DMARC checkers'], faq: [['What hurts domain reputation most?', 'Sending mail without SPF, DKIM and DMARC, appearing on blacklists, and hosting alongside abusive neighbours on a shared IP.'], ['How long does reputation take to recover?', 'Weeks of consistent, authenticated sending. There is no way to reset it instantly.']] },
++ smtp: { name: 'SMTP Server Test', title: 'SMTP Server Test — Banner, STARTTLS & Certificate Check', desc: 'Test a mail server from our server: connection, greeting banner, EHLO extensions, STARTTLS with its TLS version and certificate, and the login methods offered. Ports 25 and 587.', kw: 'smtp test, smtp server check, mail server test, starttls check, smtp connectivity', about: 'Connects to a mail server — or to the main MX host of a domain — reads its greeting, sends EHLO and, when STARTTLS is offered, upgrades the connection to report the TLS version and certificate. It never sends a message.', bullets: ['The greeting banner and every EHLO extension', 'STARTTLS support, TLS version and certificate', 'Port 25 (server to server) or 587 (submission)'], faq: [['Which SMTP port should I use?', 'Port 587 with STARTTLS for sending mail from a client, or 465 for implicit TLS. Port 25 is for server-to-server delivery and is blocked by most consumer ISPs.'], ['Does this test send an email?', 'No. It stops after the greeting, EHLO and (when offered) STARTTLS, then says QUIT.']] },
++ uptime: { name: 'Website Uptime Checker', title: 'Website Uptime Checker — Is a Site Down? Online Test', desc: 'Check whether a website is up right now from our server: the HTTP status, where redirects lead and how long the server took to answer. "Is it down" checker.', kw: 'uptime checker, is this site down, website down checker, site availability test, server status check', about: 'Requests the page from the OpenVibe server (one location, outside your network) and reports whether it answered, the status code, every redirect and the time until the server replied — enough to settle "is it down for everyone or just me".', bullets: ['Up or down, with the HTTP status', 'Every redirect hop and the final address', 'Time until the server answered'], faq: [['The site works for me but not for a friend — what now?', 'If it is up here, the server is fine and the trouble is between your friend and the site: their DNS, their network or a regional block.'], ['Does this monitor continuously?', 'No, this is an on-demand check. It tells you the state right now.']] },
robots: { name: 'Robots.txt Tester', title: 'Robots.txt Tester & Analyzer — Online Checker', desc: 'Fetch and analyse any site\'s robots.txt, check which URLs are blocked, and catch rules that hide your site from search engines.', kw: 'robots.txt tester, robots txt checker, crawl rules, disallow checker, seo crawl test', about: 'Read a site\'s robots.txt, see the rules that apply to each crawler, and test whether a specific URL would be allowed or blocked — the file where an accidental line can remove a whole site from search.', bullets: ['Rules grouped by user-agent', 'Test any URL against the live rules', 'Flags the accidental site-wide disallow'], faq: [['Does robots.txt remove a page from Google?', 'No. It stops crawling, not indexing — a blocked URL can still appear without a description. Use a noindex meta tag to remove a page properly.'], ['Should robots.txt list my sitemap?', 'Yes. A Sitemap line is the simplest way to point every crawler at your sitemap without registering anywhere.']] },
-- sitemap: { name: 'Sitemap Validator', title: 'Sitemap.xml Validator — Online XML Sitemap Checker', desc: 'Validate an XML sitemap, check its structure and URL count, and catch errors that stop search engines indexing your pages.', kw: 'sitemap validator, sitemap.xml checker, xml sitemap test, sitemap errors, seo sitemap check', about: 'Fetch and validate a sitemap against the protocol: structure, URL count, lastmod formatting and index files, so you can be sure crawlers can actually read it.', bullets: ['Structure and schema validation', 'URL count against the 50,000 entry limit', 'Follows sitemap index files to their children'], faq: [['How many URLs can one sitemap hold?', '50,000 URLs or 50 MB uncompressed. Beyond that, split it and reference the parts from a sitemap index.'], ['Does lastmod matter?', 'Yes, when it is accurate. Crawlers use it to prioritise re-crawling, but they learn to ignore it if every URL claims to have changed today.']] },
++ sitemap: { name: 'Sitemap Validator', title: 'Sitemap.xml Validator — Online XML Sitemap Checker', desc: 'Check an XML sitemap: its structure, URL count, loc and lastmod values, and sitemap index files, so search engines can read it. Instant.', kw: 'sitemap validator, sitemap.xml checker, xml sitemap test, sitemap errors, seo sitemap check', about: 'Fetches a sitemap (gzipped too) and checks it against the sitemaps.org protocol: the root element and namespace, the URL count, that every loc is an absolute URL on the same site and that lastmod dates are in W3C format. A sitemap index is followed to its first child sitemaps.', bullets: ['Structure, namespace, loc and lastmod checks', 'URL count against the 50,000 entry limit', 'Follows a sitemap index to its first three sitemaps'], faq: [['How many URLs can one sitemap hold?', '50,000 URLs or 50 MB uncompressed. Beyond that, split it and reference the parts from a sitemap index.'], ['Does lastmod matter?', 'Yes, when it is accurate. Crawlers use it to prioritise re-crawling, but they learn to ignore it if every URL claims to have changed today.']] },
};
const CATALOG = new Map();
ddiff --git a/apps/gateway/test/net-ssrf.test.js b/apps/gateway/test/net-ssrf.test.js
iindex b17490f..10760fb 100644
---- a/apps/gateway/test/net-ssrf.test.js
++++ b/apps/gateway/test/net-ssrf.test.js
@@@ -63,7 +63,9 @@ const egress = createEgress({
(async () => {
const app = express();
-- app.use('/api/net', createNetRoutes(null, null, { egress }));
++ // DNS answers for the SMTP tool's MX lookup (none: the name itself is dialled) come from a mock too.
++ const noDns = () => { const no = () => Promise.reject(Object.assign(new Error('ENODATA'), { code: 'ENODATA' })); return { resolveMx: no, resolve4: no, resolveTxt: no, resolve: no }; };
++ app.use('/api/net', createNetRoutes(null, null, { egress, resolverFor: noDns }));
app.use('/api/dev', createDevRoutes(null, null, { egress }));
const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
const base = `http://127.0.0.1:${srv.address().port}`;
@@@ -75,7 +77,7 @@ const egress = createEgress({
'0.0.0.0', '::ffff:127.0.0.1', 'fd00::1', '2130706433', 'rebind.example', 'meta.example', 'lan.example'];
for (const h of hosts) {
for (const path of [`/api/net/port?target=${q(h)}&ports=3000,4000,4910,8000`, `/api/net/ping?target=${q(h)}&count=1`,
-- `/api/net/ssl?target=${q(h)}`, `/api/net/lookup?target=${q(h)}`]) {
++ `/api/net/ssl?target=${q(h)}`, `/api/net/lookup?target=${q(h)}`, `/api/net/smtp?target=${q(h)}&port=25`]) {
dialled.length = 0;
const r = await get(path);
assert.equal(r.status, 403, `${path} → ${r.status} ${JSON.stringify(r.body)}`);
@@@ -88,7 +90,8 @@ const egress = createEgress({
const urls = ['http://localhost:3000/', 'http://127.0.0.1:4000/api', 'http://[::1]:8000/', 'http://10.1.1.1/', 'http://192.168.0.1/',
'http://169.254.169.254/latest/meta-data/', 'https://rebind.example/', 'http://meta.example/', 'http://0x7f000001:3000/', 'localhost:4100'];
for (const u of urls) {
-- for (const path of [`/api/net/headers?target=${q(u)}`, `/api/net/redirects?target=${q(u)}`, `/api/dev/opengraph?url=${q(u)}`]) {
++ for (const path of [`/api/net/headers?target=${q(u)}`, `/api/net/redirects?target=${q(u)}`, `/api/dev/opengraph?url=${q(u)}`,
++ `/api/net/robots?target=${q(u)}`, `/api/net/sitemap?target=${q(u)}`, `/api/net/uptime?target=${q(u)}`]) {
dialled.length = 0;
const r = await get(path);
assert.equal(r.status, 403, `${path} → ${r.status} ${JSON.stringify(r.body)}`);
@@@ -118,6 +121,13 @@ const egress = createEgress({
const og = await get(`/api/dev/opengraph?url=${q(`https://redirector.example${hop}`)}`);
assert.equal(og.status, 403, `opengraph ${hop}`);
assert.deepEqual(dialled, ['93.184.216.35']);
++
++ for (const tool of ['uptime', 'sitemap']) {
++ dialled.length = 0;
++ const t = await get(`/api/net/${tool}?target=${q(`https://redirector.example${hop}`)}`);
++ assert.equal(t.status, 403, `${tool} ${hop}`);
++ assert.deepEqual(dialled, ['93.184.216.35'], `${tool} ${hop}: the internal hop was never dialled`);
++ }
}
// Headers does not follow at all.
dialled.length = 0;
ddiff --git a/apps/gateway/test/net-tools.test.js b/apps/gateway/test/net-tools.test.js
nnew file mode 100644
iindex 0000000..db314d0
---- /dev/null
++++ b/apps/gateway/test/net-tools.test.js
@@@ -0,0 +1,315 @@
++'use strict';
++// The net tools that had pages but no implementation (robots, sitemap, smtp, blacklist, uptime, DNS
++// propagation), myip behind one trusted proxy hop, and the upstream cache (ip-api / ipinfo, RDAP, DoH).
++// Resolver, transports and fetch are mocks: nothing here touches a real network.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const zlib = require('zlib');
++const express = require('express');
++const { EventEmitter } = require('events');
++const { Readable } = require('stream');
++const { createEgress } = require('../../_shared/egress');
++const createNetRoutes = require('../server/net/routes');
++const { createCache } = require('../server/net/cache');
++const { parseRobots, testRobots, analyseSitemap } = require('../server/net/checks');
++const { NET_TOOLS } = require('../server/net/config');
++
++const sleep = (ms) => new Promise(r => setTimeout(r, ms));
++const DNS = { 'public.example': ['93.184.216.34'], 'mx.public.example': ['93.184.216.40'], 'site.example': ['93.184.216.50'], 'down.example': ['93.184.216.60'] };
++const lookup = (host, _opts, cb) => {
++ const a = DNS[host];
++ if (!a) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));
++ cb(null, a.map(address => ({ address, family: 4 })));
++};
++
++// ── HTTP: a few sites ──
++const SITEMAP = `<?xml version="1.0" encoding="UTF-8"?>
++<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
++ <url><loc>https://site.example/</loc><lastmod>2026-09-01</lastmod></url>
++ <url><loc>https://site.example/a?x=1&amp;y=2</loc><lastmod>01/09/2026</lastmod></url>
++ <url><loc>https://elsewhere.example/b</loc></url>
++</urlset>`;
++const INDEX = `<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
++ <sitemap><loc>https://site.example/sitemap-1.xml.gz</loc></sitemap><sitemap><loc>https://site.example/missing.xml</loc></sitemap></sitemapindex>`;
++const PAGES = {
++ 'site.example/robots.txt': [200, 'text/plain', 'User-agent: *\nDisallow: /private/\nAllow: /private/open$\n\nUser-agent: Googlebot\nUser-agent: Bingbot\nDisallow: /*.pdf$\nCrawl-delay: 5\n\nSitemap: https://site.example/sitemap_index.xml\nNoindex: /x\n'],
++ 'site.example/sitemap_index.xml': [200, 'application/xml', INDEX],
++ 'site.example/sitemap-1.xml.gz': [200, 'application/gzip', zlib.gzipSync(SITEMAP)],
++ 'site.example/missing.xml': [404, 'text/html', 'nope'],
++ 'site.example/': [200, 'text/html', '<html>home</html>'],
++ 'public.example/robots.txt': [200, 'text/plain', 'User-agent: *\nDisallow: /\n'],
++ 'public.example/sitemap.xml': [200, 'text/html', '<!doctype html><html><body>not a sitemap</body></html>'],
++ 'down.example/': [503, 'text/html', 'maintenance'],
++};
++function fakeHttp(opts, cb) {
++ const req = new EventEmitter();
++ req.destroy = (err) => { if (err) req.emit('error', err); };
++ req.end = () => {
++ const key = `${opts.hostname}${opts.path}`;
++ const hit = PAGES[key];
++ const [status, type, body] = hit || [404, 'text/plain', 'not found'];
++ const res = Readable.from([Buffer.from(body)]);
++ Object.assign(res, { statusCode: status, statusMessage: status === 200 ? 'OK' : 'Error', headers: { 'content-type': type, server: 'mock' } });
++ const answer = () => cb(res);
++ if (opts.lookup) opts.lookup(opts.hostname, {}, (err) => (err ? req.emit('error', err) : answer())); else answer();
++ };
++ return req;
++}
++
++// ── SMTP: a fake mail server on mx.public.example:25 ──
++function smtpServer(sock, { starttls = true } = {}) {
++ let tlsOn = false;
++ const say = (s, text) => setImmediate(() => s.emit('data', Buffer.from(text)));
++ const handle = (s, line) => {
++ const cmd = line.trim().toUpperCase();
++ if (cmd.startsWith('EHLO')) say(s, `250-mx.public.example hello\r\n250-SIZE 35882577\r\n${!tlsOn && starttls ? '250-STARTTLS\r\n' : ''}${tlsOn ? '250-AUTH PLAIN LOGIN\r\n' : ''}250 8BITMIME\r\n`);
++ else if (cmd === 'STARTTLS') say(s, '220 2.0.0 Ready to start TLS\r\n');
++ else if (cmd === 'QUIT') say(s, '221 2.0.0 Bye\r\n');
++ else say(s, '502 5.5.2 Error\r\n');
++ };
++ sock.write = (d) => { String(d).split('\r\n').filter(Boolean).forEach(l => handle(sock, l)); return true; };
++ sock.upgrade = () => {
++ tlsOn = true;
++ const sec = new EventEmitter();
++ sec.write = (d) => { String(d).split('\r\n').filter(Boolean).forEach(l => handle(sec, l)); return true; };
++ sec.destroy = () => {};
++ sec.getProtocol = () => 'TLSv1.3';
++ sec.getCipher = () => ({ name: 'TLS_AES_256_GCM_SHA384' });
++ sec.authorized = true;
++ sec.getPeerCertificate = () => ({ subject: { CN: 'mx.public.example' }, issuer: { O: 'Test CA' }, valid_to: new Date(Date.now() + 40 * 86400000).toUTCString(), subjectaltname: 'DNS:mx.public.example' });
++ setImmediate(() => sec.emit('secureConnect'));
++ return sec;
++ };
++ say(sock, '220 mx.public.example ESMTP ready\r\n');
++}
++const sockets = [];
++function tcpConnect({ host, port }) {
++ const s = new EventEmitter();
++ s.setTimeout = () => {}; s.destroy = () => {}; s.end = () => {};
++ sockets.push({ host, port });
++ if (host === '93.184.216.40' && (port === 25 || port === 587)) {
++ process.nextTick(() => { s.emit('connect'); smtpServer(s, { starttls: port === 587 || port === 25 }); });
++ } else if (host === '93.184.216.34' && port === 25) {
++ process.nextTick(() => s.emit('error', Object.assign(new Error('refused'), { code: 'ECONNREFUSED' })));
++ } else {
++ process.nextTick(() => s.emit('connect'));
++ }
++ return s;
++}
++const tlsUpgrade = ({ socket }) => socket.upgrade();
++
++// ── DNS: DNSBLs, MX, and ten "public resolvers" ──
++const ANSWERS = {
++ '34.216.184.93.zen.spamhaus.org': ['127.0.0.4'],
++ '34.216.184.93.psbl.surriel.com': 'ETIMEOUT',
++ 'public.example.dbl.spamhaus.org': ['127.255.255.254'],
++ 'public.example.multi.uribl.com': ['127.0.0.2'],
++};
++const TXT = { '34.216.184.93.zen.spamhaus.org': [['Listed by XBL, see https://check.spamhaus.org/']] };
++const fail = (code) => Promise.reject(Object.assign(new Error(code), { code }));
++function resolverFor(servers) {
++ const server = servers && servers[0];
++ return {
++ resolve4: (q) => { const a = ANSWERS[q] || (DNS[q] && !servers ? DNS[q] : null); return typeof a === 'string' ? fail(a) : a ? Promise.resolve(a) : fail('ENOTFOUND'); },
++ resolveTxt: (q) => (TXT[q] ? Promise.resolve(TXT[q]) : fail('ENODATA')),
++ resolveMx: (q) => (q === 'public.example' ? Promise.resolve([{ exchange: 'mx.public.example', priority: 10 }]) : fail('ENODATA')),
++ resolve: (name, type) => {
++ if (server === '223.5.5.5') return fail('ENOTFOUND');
++ if (server === '4.2.2.1') return fail('ETIMEOUT');
++ if (type === 'MX') return Promise.resolve([{ exchange: 'mx.site.example', priority: 10 }]);
++ return Promise.resolve(server === '9.9.9.9' ? ['93.184.216.99'] : ['93.184.216.50']);
++ },
++ };
++}
++
++// ── fetch: ip-api / ipinfo / RDAP / DoH, counted ──
++const fetched = [];
++async function fakeFetch(url) {
++ fetched.push(url);
++ const json = (body, status = 200) => ({ ok: status < 400, status, json: async () => body });
++ if (url.startsWith('http://ip-api.com/json/')) return json({ status: 'success', query: '8.8.8.8', country: 'United States', countryCode: 'US', city: 'Mountain View', isp: 'Google LLC', as: 'AS15169 Google LLC' });
++ if (url.startsWith('https://ipinfo.io/')) return json({ ip: '8.8.4.4', city: 'Mountain View', region: 'California', country: 'US', loc: '37.4,-122.1', org: 'AS15169 Google LLC', timezone: 'America/Los_Angeles', hostname: 'dns.google' });
++ if (url.startsWith('https://rdap.org/domain/')) return json({ ldhName: 'example.com', status: ['active'], events: [] });
++ if (url.startsWith('https://dns.google/resolve')) return json({ Answer: [{ name: 'example.com.', type: 1, TTL: 120, data: '93.184.216.34' }] });
++ return json({}, 404);
++}
++
++(async () => {
++ // ── The cache itself ──
++ {
++ let t = 0;
++ const c = createCache({ max: 2, ttlMs: 1000, now: () => t });
++ let calls = 0;
++ const slow = () => new Promise(r => setTimeout(() => r(++calls), 10));
++ const [a, b] = await Promise.all([c.wrap('k', slow), c.wrap('k', slow)]);
++ assert.deepStrictEqual([a, b, calls], [1, 1, 1], 'concurrent asks share one call');
++ assert.strictEqual(await c.wrap('k', slow), 1, 'cached');
++ t = 1001;
++ assert.strictEqual(await c.wrap('k', slow), 2, 'expired');
++ await c.wrap('zero', async () => 'x', 0);
++ assert.strictEqual(c.get('zero'), undefined, 'ttl 0 is not kept');
++ await assert.rejects(c.wrap('boom', async () => { throw new Error('upstream'); }));
++ assert.strictEqual(await c.wrap('boom', async () => 'ok'), 'ok', 'a failure is not cached');
++ c.set('x', 1); c.set('y', 2); c.set('z', 3);
++ assert.strictEqual(c.size(), 2, 'least recently used goes first');
++ }
++
++ // ── robots.txt parsing and matching (RFC 9309) ──
++ {
++ const p = parseRobots('User-agent: *\nDisallow: /private/\nAllow: /private/open$\nDisallow: /*.pdf$\n\nUser-agent: Googlebot\nDisallow: /nogoogle\nDisallow:\n');
++ assert.strictEqual(p.groups.length, 2);
++ assert.deepStrictEqual(testRobots(p, '/private/x').allowed, false);
++ assert.deepStrictEqual(testRobots(p, '/private/open').allowed, true, 'longer Allow wins');
++ assert.deepStrictEqual(testRobots(p, '/private/open/more').allowed, false, '$ anchors the end');
++ assert.deepStrictEqual(testRobots(p, '/files/report.pdf').allowed, false, '* matches anything');
++ assert.deepStrictEqual(testRobots(p, '/private/x', 'Googlebot').allowed, true, 'a specific group replaces *');
++ assert.deepStrictEqual(testRobots(p, '/nogoogle/a', 'Googlebot/2.1').allowed, false);
++ assert.deepStrictEqual(testRobots(p, '/robots.txt').allowed, true);
++ assert.match(parseRobots('User-agent: *\nDisallow: /\n').warnings[0].text, /whole site/);
++ }
++ // ── sitemap analysis ──
++ {
++ const a = analyseSitemap(SITEMAP, 'https://site.example/sitemap.xml');
++ assert.deepStrictEqual([a.kind, a.count, a.valid], ['urlset', 3, false]);
++ assert.deepStrictEqual(a.issues.map(i => i.code).sort(), ['bad-lastmod', 'other-host']);
++ assert.strictEqual(a.sample[1].loc, 'https://site.example/a?x=1&y=2', 'entities decoded');
++ const amp = analyseSitemap('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://s.example/?a=1&b=2</loc></url></urlset>', 'https://s.example/');
++ assert.ok(amp.issues.some(i => i.code === 'unescaped-ampersand'));
++ assert.strictEqual(analyseSitemap('<html></html>', 'https://s.example/').issues[0].code, 'not-a-sitemap');
++ }
++
++ // ── Through the routes ──
++ const egress = createEgress({ lookup, tcpConnect, tlsConnect: () => { throw new Error('unused'); }, httpRequest: fakeHttp, httpsRequest: fakeHttp });
++ const app = express();
++ app.set('trust proxy', 1); // what the gateway sets: the host's nginx is the one hop
++ app.use('/api/net', createNetRoutes(null, null, { egress, resolverFor, tlsUpgrade, fetch: fakeFetch }));
++ const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ const get = async (p, headers) => { const r = await fetch(base + p, { headers }); return { status: r.status, body: await r.json() }; };
++ const q = encodeURIComponent;
++ try {
++ // myip: the address nginx put last; the client's own first entry is ignored.
++ let r = await get('/api/net/myip', { 'X-Forwarded-For': '6.6.6.6, 203.0.113.9' });
++ assert.strictEqual(r.body.ip, '203.0.113.9', 'a forged first X-Forwarded-For entry is not believed');
++ r = await get('/api/net/myip', { 'X-Forwarded-For': '198.51.100.7' });
++ assert.strictEqual(r.body.ip, '198.51.100.7');
++ r = await get('/api/net/myip');
++ assert.strictEqual(r.body.ip, '127.0.0.1');
++ const gatewaySrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
++ assert.match(gatewaySrc, /app\.set\('trust proxy', 1\)/, 'the gateway trusts exactly one proxy hop');
++
++ // Upstream cache: ip-api once per IP, RDAP and DoH once per question.
++ fetched.length = 0;
++ r = await get('/api/net/ip?target=8.8.8.8');
++ assert.strictEqual(r.body.geo.city, 'Mountain View');
++ await get('/api/net/ip?target=8.8.8.8');
++ await get('/api/net/ipv4?target=8.8.8.8');
++ assert.strictEqual(fetched.filter(u => u.includes('ip-api.com')).length, 1, 'ip-api asked once');
++ await get('/api/net/rdap?target=example.com'); await get('/api/net/whois?target=example.com');
++ assert.strictEqual(fetched.filter(u => u.includes('rdap.org')).length, 1, 'RDAP asked once');
++ await get('/api/net/dns?target=example.com&types=A&doh=1'); await get('/api/net/dns?target=example.com&types=A&doh=1');
++ assert.strictEqual(fetched.filter(u => u.includes('dns.google')).length, 1, 'DoH asked once');
++ // With an ipinfo token (from the environment: the gateway has no database), HTTPS ipinfo is used.
++ process.env.NET_IPINFO_TOKEN = 'tok_test';
++ r = await get('/api/net/ip?target=8.8.4.4');
++ delete process.env.NET_IPINFO_TOKEN;
++ assert.ok(fetched.some(u => u.startsWith('https://ipinfo.io/8.8.4.4/json?token=tok_test')), 'ipinfo over HTTPS with the token');
++ assert.deepStrictEqual([r.body.geo.city, r.body.network.isp, r.body.network.as, r.body.reverse], ['Mountain View', 'Google LLC', 'AS15169 Google LLC', 'dns.google']);
++
++ // robots
++ r = await get(`/api/net/robots?target=${q('https://site.example/some/page')}&path=${q('/private/x')}`);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.found, true);
++ assert.strictEqual(r.body.groups.length, 2);
++ assert.deepStrictEqual(r.body.groups[1].agents, ['Googlebot', 'Bingbot']);
++ assert.strictEqual(r.body.groups[1].crawlDelay, '5');
++ assert.deepStrictEqual(r.body.sitemaps, ['https://site.example/sitemap_index.xml']);
++ assert.ok(r.body.warnings.some(w => /Noindex/.test(w.text)), 'unknown directives are pointed out');
++ assert.deepStrictEqual([r.body.test.allowed, r.body.test.rule.path], [false, '/private/']);
++ r = await get(`/api/net/robots?target=site.example&path=${q('/doc.pdf')}&ua=Googlebot`);
++ assert.strictEqual(r.body.test.allowed, false);
++ r = await get('/api/net/robots?target=public.example');
++ assert.match(r.body.warnings[0].text, /whole site/);
++ r = await get('/api/net/robots?target=down.example');
++ assert.strictEqual(r.body.found, false);
++ assert.match(r.body.verdict, /may crawl everything/);
++
++ // sitemap: a bare domain uses robots.txt's Sitemap line; the index is followed to its children.
++ r = await get('/api/net/sitemap?target=site.example');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual([r.body.source, r.body.kind, r.body.count, r.body.childrenTotal], ['robots.txt', 'index', 2, 2]);
++ assert.deepStrictEqual(r.body.childResults.map(c => [c.count, c.valid]), [[3, false], [0, false]], 'gzip child read; missing child reported');
++ r = await get(`/api/net/sitemap?target=${q('https://public.example/sitemap.xml')}`);
++ assert.strictEqual(r.body.valid, false);
++ assert.strictEqual(r.body.issues[0].code, 'not-a-sitemap');
++
++ // uptime
++ r = await get('/api/net/uptime?target=site.example');
++ assert.deepStrictEqual([r.body.state, r.body.status], ['up', 200]);
++ r = await get('/api/net/uptime?target=down.example');
++ assert.deepStrictEqual([r.body.state, r.body.status], ['down', 503]);
++ r = await get('/api/net/uptime?target=nowhere.example');
++ assert.deepStrictEqual([r.body.state, r.body.error], ['down', 'The name does not resolve (DNS)']);
++
++ // smtp: the domain's MX, greeting, EHLO, STARTTLS with the certificate, AUTH after TLS; never MAIL.
++ sockets.length = 0;
++ r = await get('/api/net/smtp?target=public.example&port=587');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual([r.body.host, r.body.ip, r.body.port, r.body.connected], ['mx.public.example', '93.184.216.40', 587, true]);
++ assert.strictEqual(r.body.banner.code, 220);
++ assert.ok(r.body.ehlo.extensions.includes('STARTTLS'));
++ assert.strictEqual(r.body.starttls.ok, true);
++ assert.strictEqual(r.body.starttls.protocol, 'TLSv1.3');
++ assert.deepStrictEqual([r.body.starttls.certificate.subject, r.body.starttls.certificate.hostnameMatch, r.body.starttls.certificate.trusted], ['mx.public.example', true, true]);
++ assert.deepStrictEqual(r.body.auth, ['PLAIN', 'LOGIN'], 'AUTH advertised after STARTTLS');
++ assert.ok(!r.body.error, r.body.error);
++ assert.deepStrictEqual(sockets, [{ host: '93.184.216.40', port: 587 }], 'dialled the checked MX address only');
++ r = await get('/api/net/smtp?target=93.184.216.34');
++ assert.deepStrictEqual([r.body.connected, r.body.error], [false, 'Connection refused']);
++ r = await get('/api/net/smtp?target=public.example&port=2525');
++ assert.strictEqual(r.status, 400, 'only 25 and 587');
++
++ // blacklist: an IP on the IP lists; a domain on the domain lists and its address on the IP lists.
++ r = await get('/api/net/blacklist?target=93.184.216.34');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const zen = r.body.lists.find(l => l.zone === 'zen.spamhaus.org');
++ assert.deepStrictEqual([zen.listed, zen.codes, zen.reason], [true, ['127.0.0.4'], 'Listed by XBL, see https://check.spamhaus.org/']);
++ assert.strictEqual(r.body.lists.find(l => l.zone === 'psbl.surriel.com').listed, null, 'a timeout is unknown, not clean');
++ assert.strictEqual(r.body.lists.find(l => l.zone === 'bl.spamcop.net').listed, false);
++ assert.strictEqual(r.body.listedCount, 1);
++ r = await get('/api/net/blacklist?target=public.example');
++ assert.deepStrictEqual([r.body.kind, r.body.ip], ['domain', '93.184.216.34']);
++ const dbl = r.body.lists.find(l => l.zone === 'dbl.spamhaus.org');
++ assert.deepStrictEqual([dbl.listed, /refused/.test(dbl.error)], [null, true], 'a refused query is not a listing');
++ assert.strictEqual(r.body.lists.find(l => l.zone === 'multi.uribl.com').listed, true);
++ assert.strictEqual(r.body.lists.length, 10);
++ assert.strictEqual((await get('/api/net/blacklist?target=10.0.0.1')).status, 400, 'private addresses are not looked up');
++ assert.strictEqual((await get(`/api/net/blacklist?target=${q('2001:db8::1')}`)).status, 400, 'IPv6 is said to be unsupported');
++
++ // DNS propagation: ten resolvers, the odd one out flagged, failures not voting.
++ r = await get('/api/net/dnsprop?target=site.example&type=A');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.resolvers.length, 10);
++ assert.strictEqual(r.body.agree, false);
++ assert.deepStrictEqual(r.body.resolvers.filter(x => x.differs).map(x => x.id).sort(), ['alibaba', 'quad9']);
++ assert.strictEqual(r.body.resolvers.find(x => x.id === 'level3').status, 'error');
++ assert.deepStrictEqual(r.body.majority, { answer: ['93.184.216.50'], count: 7 });
++ r = await get('/api/net/dnsprop?target=site.example&type=MX');
++ assert.deepStrictEqual(r.body.resolvers[0].answers, ['10 mx.site.example']);
++ assert.strictEqual((await get('/api/net/dnsprop?target=site.example&type=PTR')).status, 400);
++
++ // Every net tool the catalogue lists either has a route here or says it is unavailable.
++ r = await get('/api/net/tools');
++ for (const t of r.body.tools.filter(x => !x.hub)) {
++ if (t.status === 'unavailable') { assert.ok(t.unavailable, `${t.id} says why`); continue; }
++ const probe = await fetch(`${base}/api/net${t.endpoint}?target=`);
++ assert.notStrictEqual(probe.status, 404, `${t.id} → ${t.endpoint} exists`);
++ }
++ assert.deepStrictEqual(NET_TOOLS.filter(t => t.status === 'unavailable').map(t => t.id), ['traceroute', 'mtr', 'reputation']);
++ } finally {
++ srv.closeAllConnections(); srv.close();
++ }
++ await sleep(0);
++ console.log('net tools (robots, sitemap, smtp, blacklist, uptime, propagation, myip, cache): all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/deploy/scripts/add-custom-domain.sh b/deploy/scripts/add-custom-domain.sh
iindex e0d388a..18f07f8 100755
---- a/deploy/scripts/add-custom-domain.sh
++++ b/deploy/scripts/add-custom-domain.sh
@@@ -44,7 +44,9 @@ server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name $DOMAIN;
location / {
proxy_pass http://127.0.0.1:$PORT; proxy_http_version 1.1;
proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr;
-- proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https;
++ # The client address from \$remote_addr only (Cloudflare's real IP is applied globally): a client's own
++ # X-Forwarded-For is never passed on, so the apps' one-hop trust proxy cannot be fooled.
++ proxy_set_header X-Forwarded-For \$remote_addr; proxy_set_header CF-Connecting-IP \$remote_addr; proxy_set_header X-Forwarded-Proto https;
# Host-role headers come from the gateway only, never from a client.
proxy_set_header X-OV-Tool ""; proxy_set_header X-OV-Host-Role ""; proxy_set_header X-OV-Canonical-Host ""; proxy_set_header X-OV-Short-Host "";
proxy_buffering off; proxy_read_timeout 600s; proxy_send_timeout 600s;