openvibers/openvibe.tools Commits
Tools: Traceroute and MTR work (unprivileged mtr on the checked address) (#63)
Changes
@@@ -677,8 +677,8 @@dmarc: { name: 'DMARC Checker', icon: 'fa-user-shield', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for DMARC', defaultTypes: 'TXT' },// Active diagnosticsping: { 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' },++ traceroute: { name: 'Traceroute', icon: 'fa-route', cat: 'diag', endpoint: '/trace', placeholder: 'Host to trace (the path from our server)', wait: 'about 10 seconds' },++ mtr: { name: 'MTR Report', icon: 'fa-chart-line', cat: 'diag', endpoint: '/mtr', placeholder: 'Host for an MTR report (10 probes a hop, from our server)', wait: 'about 15 seconds' },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' },@@@ -975,7 +975,7 @@if (!target && !tool.auto) { toast('Please enter a domain, IP, or URL', 'error'); return; }// Show loading-- resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…</p></div>`;++ resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…${tool.wait ? ` (${tool.wait})` : ''}</p></div>`;try {// The tool's run API (plan T8, Phase 1): the fields the page collected, kept only where the@@@ -1036,6 +1036,8 @@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 === '/trace') return renderTrace(el, data, false);++ if (tool.endpoint === '/mtr') return renderTrace(el, data, true);if (tool.endpoint === '/dnsprop') return renderDnsProp(el, data);if (tool.endpoint === '/blacklist') return renderBlacklist(el, data);if (tool.endpoint === '/smtp') return renderSMTP(el, data);@@@ -1334,6 +1336,34 @@el.innerHTML = html;}++ // ── Traceroute and MTR: the path from our server, hop by hop (mtr's report; no reply = a router that stays quiet) ──++ function renderTrace(el, data, full) {++ const hops = data.hops || [];++ const ms = (v) => v === null || v === undefined ? '—' : `${v}ms`;++ const lossBadge = (l) => l === null || l === undefined ? '' : `<span class="badge ${l === 0 ? 'badge-green' : l < 100 ? 'badge-yellow' : 'badge-red'}">${l}%</span>`;++ let html = `<div class="badges">++ <span class="badge badge-cyan">${esc(data.target)} (${esc(data.ip)})</span>++ <span class="badge">${hops.length} ${hops.length === 1 ? 'hop' : 'hops'}</span>++ <span class="badge ${data.reached ? 'badge-green' : 'badge-yellow'}">${data.reached ? 'Reached' : 'Not reached'}</span>++ <span class="badge">${data.probes} probes a hop</span>++ </div>`;++ html += `<div class="card"><div class="card-head"><i class="fa-solid ${full ? 'fa-chart-line' : 'fa-route'}"></i> ${full ? 'MTR report' : 'Route'} from OpenVibe's server</div>++ <div style="overflow-x:auto"><table class="res-table"><tr><th>#</th><th>Host</th><th>Network</th><th>Loss</th>${full ? '<th>Sent</th><th>Last</th>' : ''}<th>Best</th><th>Avg</th><th>Worst</th>${full ? '<th>StDev</th>' : ''}</tr>++ ${hops.map(h => `<tr>++ <td class="val">${h.hop}</td>++ <td class="val">${h.ip ? `${h.host ? `${esc(h.host)}<br><span style="color:var(--text-muted);font-size:11px">${esc(h.ip)}</span>` : esc(h.ip)}` : '<span style="color:var(--text-muted)">no reply</span>'}</td>++ <td class="val">${h.asn ? esc(h.asn) : '—'}</td>++ <td>${h.ip ? lossBadge(h.loss) : ''}</td>++ ${full ? `<td class="val">${h.sent}</td><td class="val">${ms(h.last)}</td>` : ''}++ <td class="val">${ms(h.best)}</td><td class="val">${ms(h.avg)}</td><td class="val">${ms(h.worst)}</td>++ ${full ? `<td class="val">${h.stdev === null || h.stdev === undefined ? '—' : h.stdev}</td>` : ''}++ </tr>`).join('')}++ </table></div>++ <p style="color:var(--text-muted);font-size:12px;margin:10px 0 0">Probes are ICMP echo from OpenVibe's server (mtr). A router that does not answer is shown as no reply; loss at one hop that does not carry on to the next is usually that router rate-limiting replies, not lost traffic.</p>++ </div>`;++ el.innerHTML = html;++ }++// ── IPv4 (address or CIDR block) and IPv6 ────────────────function renderIPv4(el, data) {const v = data.ipv4 || {};@@@ -118,8 +118,8 @@ const NET_TOOLS = [{ 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', 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: 'traceroute', subdomain: 'traceroute', name: 'OpenVibeTraceroute', icon: 'fa-route', desc: 'Traceroute — visualize network path & hops', endpoint: '/trace' },++ { id: 'mtr', subdomain: 'mtr', name: 'OpenVibeMTR', icon: 'fa-chart-line', desc: 'MTR — combined ping + traceroute analysis', endpoint: '/mtr' },{ 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' },@@@ -57,6 +57,8 @@ const OUTPUT = {'/dns': out({ target: S, server: S, records: { type: 'object', description: 'record type → answers' } }, ['target', 'records']),'/dnsprop': out({ target: S, type: S, resolvers: A, agree: B, answered: I, majority: nul(O) }, ['target', 'resolvers']),'/ping': out({ target: S, ip: S, count: I, results: A, stats: out({ min: { type: 'number' }, max: { type: 'number' }, avg: { type: 'number' }, loss: { type: 'number' } }) }, ['target', 'stats']),++ '/trace': out({ target: S, ip: S, family: I, reached: B, probes: I, hops: { type: 'array', items: out({ hop: I, ip: nul(S), host: nul(S), asn: nul(S), loss: nul({ type: 'number' }), sent: I, last: nul({ type: 'number' }), avg: nul({ type: 'number' }), best: nul({ type: 'number' }), worst: nul({ type: 'number' }), stdev: nul({ type: 'number' }) }) } }, ['target', 'ip', 'hops']),++ '/mtr': out({ target: S, ip: S, family: I, reached: B, probes: I, hops: A }, ['target', 'ip', 'hops']),'/port': out({ target: S, ip: S, ports: { type: 'array', items: out({ port: I, status: S, ms: { type: ['number', 'null'] } }) } }, ['target', 'ports']),'/headers': out({ url: S, status: I, statusText: S, headers: O, timing: O, security: O, securityScore: S, server: nul(S), poweredBy: nul(S), contentType: nul(S) }, ['url', 'status', 'headers']),'/redirects': out({ originalUrl: S, finalUrl: S, hops: I, chain: A, stopped: S }, ['originalUrl', 'finalUrl', 'chain']),@@@ -89,7 +91,8 @@ const TOOLS = {dnspropagation: { input: obj({ target: target('A domain'), type: { enum: PROP_TYPES, default: 'A' } }), timeoutMs: 25000, perTarget: 10, cost: 3 },ping: { input: obj({ target: target(HOST), count: { type: 'integer', minimum: 1, maximum: 10, default: 4 } }), timeoutMs: 60000, perTarget: 6, cost: 3 },latency: { input: obj({ target: target(HOST) }), query: { count: '10' }, timeoutMs: 60000, perTarget: 6, cost: 5 },-- traceroute: {}, mtr: {},++ traceroute: { input: obj({ target: target(HOST) }), timeoutMs: 40000, perTarget: 4, cost: 5 },++ mtr: { input: obj({ target: target(HOST) }), timeoutMs: 55000, perTarget: 3, cost: 8 },port: { input: obj({ target: target(HOST), ports: { type: 'array', minItems: 1, maxItems: 20, uniqueItems: true, items: { type: 'integer', minimum: 1, maximum: 65535 }, description: 'Default: 80, 443, 22, 21, 25, 53, 3306, 5432, 8080, 8443' } }), timeoutMs: 15000, perTarget: 6, cost: 5 },headers: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300, description: 'User-Agent to send' } }), perTarget: 10, cost: 2 },curl: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300 } }), perTarget: 10, cost: 2 },@@@ -33,6 +33,7 @@ const IPAPI_FIELDS = 'status,message,country,countryCode,region,regionName,city,* report mode too. The pages call these routes without a session: people are counted by address.*/const { SPECS: NET_SPECS } = require('./descriptors');++const { createTracer } = require('./trace');// endpoint ('/dns') → the descriptors served by it (dns, dig, nslookup, mx, txt, …)const BY_ENDPOINT = new Map();@@@ -151,6 +152,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {const checks = createChecks({ egress, cache, ...(opts.resolverFor && { resolverFor: opts.resolverFor }), ...(opts.tlsUpgrade && { tlsUpgrade: opts.tlsUpgrade }) });const reverseDns = opts.reverse || ((ip) => dns.reverse(ip));const guard = opts.guard || null;++ // Traceroute and MTR (net/trace.js): mtr on the checked address; tests pass a tracer with a fake mtr.++ const tracer = opts.tracer || createTracer({ reverse: reverseDns });// ── Helpers ──────────────────────────────────────────────@@@ -820,6 +823,27 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {}});++ // ═══════════════════════════════════════════════════════════++ // Traceroute and MTR (from this server; net/trace.js)++ // ═══════════════════════════════════════════════════════════++++ for (const [endpoint, mode, what] of [['/trace', 'trace', 'Traceroute'], ['/mtr', 'mtr', 'MTR']]) {++ router.get(`${endpoint}/:target?`, async (req, res) => {++ try {++ const target = parseTarget(req.params.target || req.query.target);++ if (!target) return fail(res, 'Please provide a host');++ // Resolve + check; mtr is given the checked address, never the name.++ const dest = await egress.resolve(target, { prefer: req.query.v === '6' ? 6 : 4 });++ const run = await tracer.run(mode, dest);++ ok(res, { target, ip: dest.address, family: net.isIP(dest.address), ...run });++ } catch (err) {++ if (err && err.code === 'busy') { res.set('Retry-After', '10'); return fail(res, err.message, 503, 'busy'); }++ if (err && (err.code === 'trace_timeout' || err.code === 'trace_failed' || err.code === 'trace_unavailable')) return fail(res, err.message, 502, err.code);++ failFrom(res, err, `${what} failed`);++ }++ });++ }++// ═══════════════════════════════════════════════════════════// Supertool Lookup (combines multiple tools)// ═══════════════════════════════════════════════════════════nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Traceroute and MTR from this server, without raw sockets.++//++// Both run mtr (0.95+) in report mode with JSON output. Its mtr-packet helper sends ICMP echo through++// Linux's unprivileged ICMP sockets (net.ipv4.ping_group_range), so it needs no capability and runs++// under the unit's NoNewPrivileges. ICMP echo reaches hosts that ignore traceroute's classic UDP++// probes (9.9.9.9 answers at hop 11; tracepath waited out all 30 hops), probes every hop at once, and++// stops after 5 silent hops in a row (-U 5).++//++// The target is always the address the SSRF guard checked (never a name: no rebinding), passed in++// argv to execFile (no shell). At most RUNNING_MAX runs at once across everyone; a caller over that++// gets `busy` and tries again. The hops are the provider's own path; private hop addresses are shown++// as mtr reports them (they are answers, never dialled).++// ═══════════════════════════════════════════════════════════════++++const { execFile } = require('child_process');++const net = require('net');++++const MTR_BIN = process.env.NET_MTR_BIN || '/usr/bin/mtr';++const MAX_HOPS = 30;++const MAX_UNKNOWN = 5;++const RUNNING_MAX = 2;++const MODES = {++ // A quick path: three probes a hop.++ trace: { cycles: 3, timeoutMs: 30000 },++ // Loss and jitter per hop: ten probes a hop (about 11 s; non-root mtr cannot go under a second between rounds).++ mtr: { cycles: 10, timeoutMs: 45000 },++};++++/** Run the mtr binary: resolves { stdout } or rejects with a coded error. */++function execMtr(args, timeoutMs) {++ return new Promise((resolve, reject) => {++ execFile(MTR_BIN, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024, killSignal: 'SIGKILL', env: { PATH: '/usr/bin:/bin', LANG: 'C' } }, (err, stdout, stderr) => {++ if (err) {++ const e = new Error(err.killed ? 'The trace took too long and was stopped' : (String(stderr || err.message).trim().split('\n')[0] || 'mtr failed'));++ e.code = err.killed ? 'trace_timeout' : err.code === 'ENOENT' ? 'trace_unavailable' : 'trace_failed';++ return reject(e);++ }++ resolve({ stdout: String(stdout) });++ });++ });++}++++const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 100) / 100 : null);++++/** mtr's JSON report → hops in order: { hop, ip, asn, loss, sent, last, avg, best, worst, stdev } (ip null = no reply). */++function parseReport(stdout) {++ let doc;++ try { doc = JSON.parse(stdout); } catch { const e = new Error('mtr gave an answer that is not JSON'); e.code = 'trace_failed'; throw e; }++ const hubs = (doc && doc.report && Array.isArray(doc.report.hubs)) ? doc.report.hubs : [];++ return hubs.map((h) => {++ const ip = typeof h.host === 'string' && net.isIP(h.host) ? h.host : null;++ const asn = typeof h.ASN === 'string' && /^AS\d+$/.test(h.ASN) ? h.ASN : null;++ return {++ hop: Number(h.count) || 0, ip, asn,++ loss: num(h['Loss%']), sent: Number(h.Snt) || 0,++ last: ip ? num(h.Last) : null, avg: ip ? num(h.Avg) : null, best: ip ? num(h.Best) : null,++ worst: ip ? num(h.Wrst) : null, stdev: ip ? num(h.StDev) : null,++ };++ }).filter((h) => h.hop > 0);++}++++/**++ * createTracer({ exec, reverse }) → { run(mode, dest) }. `exec(args, timeoutMs)` and `reverse(ip)` are++ * for tests; `dest` is egress.resolve()'s answer ({ address, family }).++ */++function createTracer({ exec = execMtr, reverse = null, max = RUNNING_MAX } = {}) {++ let running = 0;++ const names = new Map(); // ip → PTR name (or '' for none), a small bounded cache++++ async function nameOf(ip) {++ if (!reverse || !ip) return null;++ if (names.has(ip)) return names.get(ip) || null;++ let name = '';++ try {++ const got = await Promise.race([reverse(ip), new Promise((r) => setTimeout(() => r([]), 1500).unref())]);++ name = Array.isArray(got) && typeof got[0] === 'string' ? got[0].slice(0, 253) : '';++ } catch { name = ''; }++ if (names.size > 2000) names.delete(names.keys().next().value);++ names.set(ip, name);++ return name || null;++ }++++ async function run(mode, dest) {++ const m = MODES[mode];++ if (!m) throw new Error(`trace: unknown mode ${mode}`);++ if (!dest || !net.isIP(dest.address)) { const e = new Error('No address to trace'); e.code = 'trace_failed'; throw e; }++ if (running >= max) { const e = new Error('Other traces are running from this server right now; try again in a few seconds'); e.code = 'busy'; throw e; }++ running++;++ try {++ const family = net.isIP(dest.address) === 6 ? '-6' : '-4';++ const { stdout } = await exec([family, '-n', '-z', '-j', '-c', String(m.cycles), '-m', String(MAX_HOPS), '-U', String(MAX_UNKNOWN), '--', dest.address], m.timeoutMs);++ const hops = parseReport(stdout);++ await Promise.all(hops.map(async (h) => { h.host = await nameOf(h.ip); }));++ const last = hops.length ? hops[hops.length - 1] : null;++ return { hops, reached: !!(last && last.ip === dest.address), probes: m.cycles };++ } finally { running--; }++ }++++ return { run, get running() { return running; } };++}++++module.exports = { createTracer, parseReport, MODES, MAX_HOPS };@@@ -17,7 +17,7 @@ const FAMILIES = [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?', '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.'],++ ['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 send ICMP echo hop by hop (mtr).'],['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.'],],},@@@ -376,9 +376,9 @@ const NET = {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 (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.']] },++ 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: 'This test times TCP connections: 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. A web server answers it even when a firewall drops ICMP; for ICMP hop by hop, use the MTR report.', 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?', 'A timed TCP connection answers the same question — is the host up, how far away is it — and passes firewalls that drop ICMP. The traceroute and MTR tools use ICMP echo when you want the path.'], ['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 — See the Network Path to Any Host', desc: 'Trace the route from our server to any host: every hop with its name, network (AS number) and best, average and worst time. IPv4 and IPv6. No install.', kw: 'traceroute online, trace route, network path, tracert online, hop by hop latency, visual traceroute', about: 'A traceroute shows the routers between our server and the host, one hop at a time. Each hop is probed three times with ICMP echo (mtr), named by reverse DNS where it has a name, and tagged with the network (AS number) that runs it, so you can see where the path leaves one provider for the next and where the time goes.', bullets: ['Every hop with its name, address and network (AS number)', 'Best, average and worst time per hop, from three probes', 'IPv4, or IPv6 when the host has only an IPv6 address'], faq: [['How do I read a traceroute?', 'Look for the point where latency jumps sharply and stays high, or where replies stop for good. The hop before it usually names the network where the trouble starts.'], ['Why do some hops show no reply?', 'Many routers do not answer probes addressed to them, or answer only a few a second. A silent hop in the middle of a path that carries on is normal; replies that stop for good mean the path ends there or a firewall drops the probes.'], ['Where does the trace start?', 'At the OpenVibe server, outside your network. To see the path from your own connection, run traceroute (Windows: tracert) on your machine.']] },++ mtr: { name: 'MTR Report', title: 'Online MTR Report — Packet Loss and Latency per Hop', desc: 'Run an MTR report from our server to any host: ten probes per hop with loss, last, best, average and worst time and jitter (StDev), plus each hop\'s network. IPv4 and IPv6.', kw: 'mtr online, mtr report, my traceroute, packet loss test, network path analysis, jitter test', about: 'MTR combines traceroute and ping: it sends ten ICMP echo probes to every hop on the path and reports, per hop, how many were lost and how the round-trip time varied. That separates a router that is merely slow to answer from a path that really loses traffic, which is the evidence a network operator asks for.', bullets: ['Loss, last, best, average and worst time and StDev for every hop', 'Each hop\'s name and network (AS number)', 'Ten probes a hop in about fifteen seconds, from the OpenVibe server'], 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.'], ['A middle hop shows loss but the destination shows none. Is something wrong?', 'Usually not. Routers limit how often they answer probes addressed to themselves while forwarding traffic normally. Loss that starts at a hop and carries on to every hop after it, including the destination, is real loss.']] },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.']] },@@@ -332,7 +332,7 @@ async function fakeFetch(url) {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']);++ assert.deepStrictEqual(NET_TOOLS.filter(t => t.status === 'unavailable').map(t => t.id), ['reputation']);} finally {srv.closeAllConnections(); srv.close();}nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++// Traceroute and MTR (net/trace.js, /api/net/trace and /api/net/mtr): mtr's JSON report becomes hops with names and++// networks; mtr is given only the address the SSRF guard checked, in argv after `--`; a private target, or a name++// that resolves to one, is refused before mtr runs; two runs at once is the limit (busy, Retry-After); a timeout++// is a 502 with its code. The resolver and mtr are fakes: nothing here touches a real network.++const assert = require('assert');++const express = require('express');++const { createEgress } = require('../../_shared/egress');++const createNetRoutes = require('../server/net/routes');++const { createTracer, parseReport, MODES } = require('../server/net/trace');++++const DNS = { 'public.example': ['93.184.216.34'], 'lan.example': ['192.168.1.20'], 'six.example': [] };++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 })));++};++const egress = createEgress({ lookup });++++const REPORT = (dst) => JSON.stringify({ report: { mtr: { dst, tests: 3 }, hubs: [++ { count: 1, host: '15.204.72.1', ASN: 'AS16276', 'Loss%': 0, Snt: 3, Last: 0.17, Avg: 0.166, Best: 0.147, Wrst: 0.182, StDev: 0.018 },++ { count: 2, host: '???', ASN: 'AS???', 'Loss%': 100, Snt: 3, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0 },++ { count: 3, host: dst, ASN: 'AS15133', 'Loss%': 33.3333, Snt: 3, Last: 5.4, Avg: 5.4567, Best: 4.7, Wrst: 6.2, StDev: 0.7 },++] } });++++(async () => {++ // ── The report ──++ const hops = parseReport(REPORT('93.184.216.34'));++ assert.deepStrictEqual(hops.map(h => [h.hop, h.ip, h.asn]), [[1, '15.204.72.1', 'AS16276'], [2, null, null], [3, '93.184.216.34', 'AS15133']]);++ assert.strictEqual(hops[2].avg, 5.46, 'times rounded to 0.01 ms');++ assert.strictEqual(hops[2].loss, 33.33);++ assert.strictEqual(hops[1].best, null, 'no reply has no times');++ assert.throws(() => parseReport('not json'), (e) => e.code === 'trace_failed');++++ // ── The tracer: argv, names, reached, the limit ──++ const calls = [];++ let release = null;++ const exec = async (args, timeoutMs) => {++ calls.push({ args, timeoutMs });++ if (release) await new Promise(r => { release.push(r); });++ return { stdout: REPORT(args[args.length - 1]) };++ };++ const names = { '15.204.72.1': ['vl1.example-provider.net'] };++ const tracer = createTracer({ exec, reverse: async (ip) => names[ip] || [] });++ let run = await tracer.run('trace', { address: '93.184.216.34', family: 4 });++ assert.deepStrictEqual(calls[0].args, ['-4', '-n', '-z', '-j', '-c', '3', '-m', '30', '-U', '5', '--', '93.184.216.34']);++ assert.strictEqual(calls[0].timeoutMs, MODES.trace.timeoutMs);++ assert.strictEqual(run.reached, true);++ assert.strictEqual(run.probes, 3);++ assert.strictEqual(run.hops[0].host, 'vl1.example-provider.net', 'a hop with a PTR name is named');++ assert.strictEqual(run.hops[2].host, null);++ run = await tracer.run('mtr', { address: '2606:4700:4700::1111', family: 6 });++ assert.deepStrictEqual(calls[1].args.slice(0, 6), ['-6', '-n', '-z', '-j', '-c', '10']);++ assert.strictEqual(calls[1].args[calls[1].args.length - 1], '2606:4700:4700::1111');++ await assert.rejects(tracer.run('trace', { address: 'public.example' }), (e) => e.code === 'trace_failed', 'only an address is traced');++++ release = [];++ const a = tracer.run('trace', { address: '93.184.216.34' });++ const b = tracer.run('mtr', { address: '93.184.216.34' });++ await assert.rejects(tracer.run('trace', { address: '93.184.216.34' }), (e) => e.code === 'busy', 'a third run at once is refused');++ release.forEach(r => r()); await Promise.all([a, b]);++ release = null;++ assert.strictEqual(tracer.running, 0);++++ // ── The routes ──++ const routeCalls = [];++ const fakeTracer = {++ async run(mode, dest) {++ routeCalls.push([mode, dest.address]);++ if (dest.address === '93.184.216.34' && mode === 'mtr' && routeCalls.length > 3) throw Object.assign(new Error('The trace took too long and was stopped'), { code: 'trace_timeout' });++ return { hops: parseReport(REPORT(dest.address)), reached: true, probes: MODES[mode].cycles };++ },++ };++ const app = express();++ app.use('/api/net', createNetRoutes(null, null, { egress, tracer: fakeTracer, reverse: async () => [] }));++ const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const get = async (p) => { const r = await fetch(`http://127.0.0.1:${srv.address().port}${p}`); return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json() }; };++ try {++ let r = await get('/api/net/trace?target=public.example');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.ip, '93.184.216.34');++ assert.strictEqual(r.body.family, 4);++ assert.strictEqual(r.body.hops.length, 3);++ r = await get('/api/net/mtr/public.example');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.probes, 10);++ assert.deepStrictEqual(routeCalls, [['trace', '93.184.216.34'], ['mtr', '93.184.216.34']], 'mtr gets the checked address, not the name');++++ for (const t of ['127.0.0.1', '10.0.0.8', '192.168.1.1', '169.254.169.254', 'lan.example', '[::1]', 'fd00::1']) {++ r = await get(`/api/net/trace?target=${encodeURIComponent(t)}`);++ assert.strictEqual(r.status, 403, `${t} is refused`);++ }++ assert.strictEqual(routeCalls.length, 2, 'nothing private reached mtr');++ r = await get('/api/net/trace');++ assert.strictEqual(r.status, 400);++++ const busyApp = express();++ busyApp.use('/api/net', createNetRoutes(null, null, { egress, tracer: { run: async () => { throw Object.assign(new Error('busy'), { code: 'busy' }); } } }));++ const s2 = await new Promise(res => { const s = busyApp.listen(0, '127.0.0.1', () => res(s)); });++ const r2 = await fetch(`http://127.0.0.1:${s2.address().port}/api/net/mtr?target=public.example`);++ assert.strictEqual(r2.status, 503);++ assert.strictEqual(r2.headers.get('retry-after'), '10');++ assert.strictEqual((await r2.json()).code, 'busy');++ s2.close();++++ routeCalls.push(['x', 'x'], ['x', 'x']);++ r = await get('/api/net/mtr?target=public.example');++ assert.strictEqual(r.status, 502);++ assert.strictEqual(r.body.code, 'trace_timeout');++ } finally { srv.close(); }++ console.log('net-trace: ok');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -109,9 +109,9 @@ function checkProblem(r, status, code, where) {checkProblem(await run('nosuchtool', {}), 404, 'tools.tool.not_found', 'unknown');checkProblem(await run('yt', {}), 404, 'tools.tool.not_runnable', 'yt: api false');checkProblem(await run('regex', { input: {} }), 404, 'tools.tool.not_runnable', 'regex: page only');-- r = await run('traceroute', {});++ r = await run('reputation', {});checkProblem(r, 503, 'tools.tool.unavailable', 'descriptor status unavailable');-- assert.match(r.body.detail, /raw network access/);++ assert.match(r.body.detail, /not available yet/);checkProblem(await run('protectpdf', { input: { password: 'x' } }), 503, 'tools.tool.unavailable', 'qpdf missing on docs');checkProblem(await call('/api/v1/tools/jsonminify/run', { method: 'GET' }), 405, 'method_not_allowed', 'GET');@@@ -190,7 +190,7 @@ const get = async (url, headers = {}, method = 'GET') => {for (const id of ['protectpdf', 'unlockpdf', 'pdf2jpg', 'heic', 'mp3', 'merge', 'yt']) assert.strictEqual((await status(G, id)).status, 'unavailable', `gateway: ${id}`);for (const id of ['png', 'mergepdf', 'fancy', 'dns']) assert.strictEqual((await status(G, id)).status, 'stable', `gateway: ${id}`);l = await count('status=unavailable');-- assert.ok(l.tools.length >= 3 + 36 + 5 && contracts.tools.checkList(l).valid, 'status filter');++ assert.ok(l.tools.length >= 1 + 36 + 5 && contracts.tools.checkList(l).valid, 'status filter'); // reputation (net), then the tools without binaries here// Readiness: the registry is checked, and it passes.const ready = (await get(`${G}/api/ready`)).body;
@@ -677,8 +677,8 @@
@@ -677,8 +677,8 @@
dmarc: { name: 'DMARC Checker', icon: 'fa-user-shield', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for DMARC', defaultTypes: 'TXT' },
dmarc: { name: 'DMARC Checker', icon: 'fa-user-shield', cat: 'dns', endpoint: '/dns', placeholder: 'Domain for DMARC', defaultTypes: 'TXT' },
// Active diagnostics
// Active diagnostics
ping: { name: 'Ping Test (TCP)', icon: 'fa-satellite-dish', cat: 'diag', endpoint: '/ping', placeholder: 'Host to ping (TCP port 443, from our server)' },
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' },
+ traceroute: { name: 'Traceroute', icon: 'fa-route', cat: 'diag', endpoint: '/trace', placeholder: 'Host to trace (the path from our server)', wait: 'about 10 seconds' },
+ mtr: { name: 'MTR Report', icon: 'fa-chart-line', cat: 'diag', endpoint: '/mtr', placeholder: 'Host for an MTR report (10 probes a hop, from our server)', wait: 'about 15 seconds' },
port: { name: 'Port Checker', icon: 'fa-door-open', cat: 'diag', endpoint: '/port', placeholder: 'Host to scan ports' },
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' },
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' },
redirects: { name: 'Redirect Checker', icon: 'fa-share', cat: 'diag', endpoint: '/redirects', placeholder: 'URL to follow redirects' },
@@ -975,7 +975,7 @@
@@ -975,7 +975,7 @@
if (!target && !tool.auto) { toast('Please enter a domain, IP, or URL', 'error'); return; }
if (!target && !tool.auto) { toast('Please enter a domain, IP, or URL', 'error'); return; }
// Show loading
// Show loading
- resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…</p></div>`;
+ resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…${tool.wait ? ` (${tool.wait})` : ''}</p></div>`;
try {
try {
// The tool's run API (plan T8, Phase 1): the fields the page collected, kept only where the
// The tool's run API (plan T8, Phase 1): the fields the page collected, kept only where the
@@ -1036,6 +1036,8 @@
@@ -1036,6 +1036,8 @@
if (tool.endpoint === '/redirects') return renderRedirects(el, data);
if (tool.endpoint === '/redirects') return renderRedirects(el, data);
if (tool.endpoint === '/port') return renderPort(el, data);
if (tool.endpoint === '/port') return renderPort(el, data);
if (tool.endpoint === '/ping') return renderPing(el, data);
if (tool.endpoint === '/ping') return renderPing(el, data);
+ if (tool.endpoint === '/trace') return renderTrace(el, data, false);
+ if (tool.endpoint === '/mtr') return renderTrace(el, data, true);
if (tool.endpoint === '/dnsprop') return renderDnsProp(el, data);
if (tool.endpoint === '/dnsprop') return renderDnsProp(el, data);
if (tool.endpoint === '/blacklist') return renderBlacklist(el, data);
if (tool.endpoint === '/blacklist') return renderBlacklist(el, data);
if (tool.endpoint === '/smtp') return renderSMTP(el, data);
if (tool.endpoint === '/smtp') return renderSMTP(el, data);
@@ -1334,6 +1336,34 @@
@@ -1334,6 +1336,34 @@
el.innerHTML = html;
el.innerHTML = html;
}
}
+ // ── Traceroute and MTR: the path from our server, hop by hop (mtr's report; no reply = a router that stays quiet) ──
+ function renderTrace(el, data, full) {
+ const hops = data.hops || [];
+ const ms = (v) => v === null || v === undefined ? '—' : `${v}ms`;
+ const lossBadge = (l) => l === null || l === undefined ? '' : `<span class="badge ${l === 0 ? 'badge-green' : l < 100 ? 'badge-yellow' : 'badge-red'}">${l}%</span>`;
+ let html = `<div class="badges">
+ <span class="badge badge-cyan">${esc(data.target)} (${esc(data.ip)})</span>
+ <span class="badge">${hops.length} ${hops.length === 1 ? 'hop' : 'hops'}</span>
+ <span class="badge ${data.reached ? 'badge-green' : 'badge-yellow'}">${data.reached ? 'Reached' : 'Not reached'}</span>
+ <span class="badge">${data.probes} probes a hop</span>
+ </div>`;
+ html += `<div class="card"><div class="card-head"><i class="fa-solid ${full ? 'fa-chart-line' : 'fa-route'}"></i> ${full ? 'MTR report' : 'Route'} from OpenVibe's server</div>
+ <div style="overflow-x:auto"><table class="res-table"><tr><th>#</th><th>Host</th><th>Network</th><th>Loss</th>${full ? '<th>Sent</th><th>Last</th>' : ''}<th>Best</th><th>Avg</th><th>Worst</th>${full ? '<th>StDev</th>' : ''}</tr>
+ ${hops.map(h => `<tr>
+ <td class="val">${h.hop}</td>
+ <td class="val">${h.ip ? `${h.host ? `${esc(h.host)}<br><span style="color:var(--text-muted);font-size:11px">${esc(h.ip)}</span>` : esc(h.ip)}` : '<span style="color:var(--text-muted)">no reply</span>'}</td>
+ <td class="val">${h.asn ? esc(h.asn) : '—'}</td>
+ <td>${h.ip ? lossBadge(h.loss) : ''}</td>
+ ${full ? `<td class="val">${h.sent}</td><td class="val">${ms(h.last)}</td>` : ''}
+ <td class="val">${ms(h.best)}</td><td class="val">${ms(h.avg)}</td><td class="val">${ms(h.worst)}</td>
+ ${full ? `<td class="val">${h.stdev === null || h.stdev === undefined ? '—' : h.stdev}</td>` : ''}
+ </tr>`).join('')}
+ </table></div>
+ <p style="color:var(--text-muted);font-size:12px;margin:10px 0 0">Probes are ICMP echo from OpenVibe's server (mtr). A router that does not answer is shown as no reply; loss at one hop that does not carry on to the next is usually that router rate-limiting replies, not lost traffic.</p>
+ </div>`;
+ el.innerHTML = html;
+ }
+
// ── IPv4 (address or CIDR block) and IPv6 ────────────────
// ── IPv4 (address or CIDR block) and IPv6 ────────────────
function renderIPv4(el, data) {
function renderIPv4(el, data) {
const v = data.ipv4 || {};
const v = data.ipv4 || {};
@@ -118,8 +118,8 @@ const NET_TOOLS = [
@@ -118,8 +118,8 @@ const NET_TOOLS = [
{ id: 'dmarc', subdomain: 'dmarc', name: 'OpenVibeDMARC', icon: 'fa-user-shield', desc: 'DMARC policy checker', endpoint: '/dns' },
{ id: 'dmarc', subdomain: 'dmarc', name: 'OpenVibeDMARC', icon: 'fa-user-shield', desc: 'DMARC policy checker', endpoint: '/dns' },
// Active diagnostics
// Active diagnostics
{ id: 'ping', subdomain: 'ping', name: 'OpenVibePing', icon: 'fa-satellite-dish', desc: 'Ping from multiple global regions', endpoint: '/ping' },
{ 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: 'traceroute', subdomain: 'traceroute', name: 'OpenVibeTraceroute', icon: 'fa-route', desc: 'Traceroute — visualize network path & hops', endpoint: '/trace' },
+ { id: 'mtr', subdomain: 'mtr', name: 'OpenVibeMTR', icon: 'fa-chart-line', desc: 'MTR — combined ping + traceroute analysis', endpoint: '/mtr' },
{ id: 'port', subdomain: 'port', name: 'OpenVibePortCheck', icon: 'fa-door-open', desc: 'Port scanner — check if ports are open', endpoint: '/port' },
{ 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: '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: 'redirects', subdomain: 'redirects', name: 'OpenVibeRedirects', icon: 'fa-share', desc: 'Redirect chain tracer — follow all hops', endpoint: '/redirects' },
@@ -57,6 +57,8 @@ const OUTPUT = {
@@ -57,6 +57,8 @@ const OUTPUT = {
'/dns': out({ target: S, server: S, records: { type: 'object', description: 'record type → answers' } }, ['target', 'records']),
'/dns': out({ target: S, server: S, records: { type: 'object', description: 'record type → answers' } }, ['target', 'records']),
'/dnsprop': out({ target: S, type: S, resolvers: A, agree: B, answered: I, majority: nul(O) }, ['target', 'resolvers']),
'/dnsprop': out({ target: S, type: S, resolvers: A, agree: B, answered: I, majority: nul(O) }, ['target', 'resolvers']),
'/ping': out({ target: S, ip: S, count: I, results: A, stats: out({ min: { type: 'number' }, max: { type: 'number' }, avg: { type: 'number' }, loss: { type: 'number' } }) }, ['target', 'stats']),
'/ping': out({ target: S, ip: S, count: I, results: A, stats: out({ min: { type: 'number' }, max: { type: 'number' }, avg: { type: 'number' }, loss: { type: 'number' } }) }, ['target', 'stats']),
+ '/trace': out({ target: S, ip: S, family: I, reached: B, probes: I, hops: { type: 'array', items: out({ hop: I, ip: nul(S), host: nul(S), asn: nul(S), loss: nul({ type: 'number' }), sent: I, last: nul({ type: 'number' }), avg: nul({ type: 'number' }), best: nul({ type: 'number' }), worst: nul({ type: 'number' }), stdev: nul({ type: 'number' }) }) } }, ['target', 'ip', 'hops']),
+ '/mtr': out({ target: S, ip: S, family: I, reached: B, probes: I, hops: A }, ['target', 'ip', 'hops']),
'/port': out({ target: S, ip: S, ports: { type: 'array', items: out({ port: I, status: S, ms: { type: ['number', 'null'] } }) } }, ['target', 'ports']),
'/port': out({ target: S, ip: S, ports: { type: 'array', items: out({ port: I, status: S, ms: { type: ['number', 'null'] } }) } }, ['target', 'ports']),
'/headers': out({ url: S, status: I, statusText: S, headers: O, timing: O, security: O, securityScore: S, server: nul(S), poweredBy: nul(S), contentType: nul(S) }, ['url', 'status', 'headers']),
'/headers': out({ url: S, status: I, statusText: S, headers: O, timing: O, security: O, securityScore: S, server: nul(S), poweredBy: nul(S), contentType: nul(S) }, ['url', 'status', 'headers']),
'/redirects': out({ originalUrl: S, finalUrl: S, hops: I, chain: A, stopped: S }, ['originalUrl', 'finalUrl', 'chain']),
'/redirects': out({ originalUrl: S, finalUrl: S, hops: I, chain: A, stopped: S }, ['originalUrl', 'finalUrl', 'chain']),
@@ -89,7 +91,8 @@ const TOOLS = {
@@ -89,7 +91,8 @@ const TOOLS = {
dnspropagation: { input: obj({ target: target('A domain'), type: { enum: PROP_TYPES, default: 'A' } }), timeoutMs: 25000, perTarget: 10, cost: 3 },
dnspropagation: { input: obj({ target: target('A domain'), type: { enum: PROP_TYPES, default: 'A' } }), timeoutMs: 25000, perTarget: 10, cost: 3 },
ping: { input: obj({ target: target(HOST), count: { type: 'integer', minimum: 1, maximum: 10, default: 4 } }), timeoutMs: 60000, perTarget: 6, cost: 3 },
ping: { input: obj({ target: target(HOST), count: { type: 'integer', minimum: 1, maximum: 10, default: 4 } }), timeoutMs: 60000, perTarget: 6, cost: 3 },
latency: { input: obj({ target: target(HOST) }), query: { count: '10' }, timeoutMs: 60000, perTarget: 6, cost: 5 },
latency: { input: obj({ target: target(HOST) }), query: { count: '10' }, timeoutMs: 60000, perTarget: 6, cost: 5 },
- traceroute: {}, mtr: {},
+ traceroute: { input: obj({ target: target(HOST) }), timeoutMs: 40000, perTarget: 4, cost: 5 },
+ mtr: { input: obj({ target: target(HOST) }), timeoutMs: 55000, perTarget: 3, cost: 8 },
port: { input: obj({ target: target(HOST), ports: { type: 'array', minItems: 1, maxItems: 20, uniqueItems: true, items: { type: 'integer', minimum: 1, maximum: 65535 }, description: 'Default: 80, 443, 22, 21, 25, 53, 3306, 5432, 8080, 8443' } }), timeoutMs: 15000, perTarget: 6, cost: 5 },
port: { input: obj({ target: target(HOST), ports: { type: 'array', minItems: 1, maxItems: 20, uniqueItems: true, items: { type: 'integer', minimum: 1, maximum: 65535 }, description: 'Default: 80, 443, 22, 21, 25, 53, 3306, 5432, 8080, 8443' } }), timeoutMs: 15000, perTarget: 6, cost: 5 },
headers: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300, description: 'User-Agent to send' } }), perTarget: 10, cost: 2 },
headers: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300, description: 'User-Agent to send' } }), perTarget: 10, cost: 2 },
curl: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300 } }), perTarget: 10, cost: 2 },
curl: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300 } }), perTarget: 10, cost: 2 },
@@ -33,6 +33,7 @@ const IPAPI_FIELDS = 'status,message,country,countryCode,region,regionName,city,
@@ -33,6 +33,7 @@ const IPAPI_FIELDS = 'status,message,country,countryCode,region,regionName,city,
* report mode too. The pages call these routes without a session: people are counted by address.
* report mode too. The pages call these routes without a session: people are counted by address.
*/
*/
const { SPECS: NET_SPECS } = require('./descriptors');
const { SPECS: NET_SPECS } = require('./descriptors');
+const { createTracer } = require('./trace');
// endpoint ('/dns') → the descriptors served by it (dns, dig, nslookup, mx, txt, …)
// endpoint ('/dns') → the descriptors served by it (dns, dig, nslookup, mx, txt, …)
const BY_ENDPOINT = new Map();
const BY_ENDPOINT = new Map();
@@ -151,6 +152,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
@@ -151,6 +152,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const checks = createChecks({ egress, cache, ...(opts.resolverFor && { resolverFor: opts.resolverFor }), ...(opts.tlsUpgrade && { tlsUpgrade: opts.tlsUpgrade }) });
const checks = createChecks({ egress, cache, ...(opts.resolverFor && { resolverFor: opts.resolverFor }), ...(opts.tlsUpgrade && { tlsUpgrade: opts.tlsUpgrade }) });
const reverseDns = opts.reverse || ((ip) => dns.reverse(ip));
const reverseDns = opts.reverse || ((ip) => dns.reverse(ip));
const guard = opts.guard || null;
const guard = opts.guard || null;
+ // Traceroute and MTR (net/trace.js): mtr on the checked address; tests pass a tracer with a fake mtr.
+ const tracer = opts.tracer || createTracer({ reverse: reverseDns });
// ── Helpers ──────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────
@@ -820,6 +823,27 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
@@ -820,6 +823,27 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
}
}
});
});
+ // ═══════════════════════════════════════════════════════════
+ // Traceroute and MTR (from this server; net/trace.js)
+ // ═══════════════════════════════════════════════════════════
+
+ for (const [endpoint, mode, what] of [['/trace', 'trace', 'Traceroute'], ['/mtr', 'mtr', 'MTR']]) {
+ router.get(`${endpoint}/:target?`, async (req, res) => {
+ try {
+ const target = parseTarget(req.params.target || req.query.target);
+ if (!target) return fail(res, 'Please provide a host');
+ // Resolve + check; mtr is given the checked address, never the name.
+ const dest = await egress.resolve(target, { prefer: req.query.v === '6' ? 6 : 4 });
+ const run = await tracer.run(mode, dest);
+ ok(res, { target, ip: dest.address, family: net.isIP(dest.address), ...run });
+ } catch (err) {
+ if (err && err.code === 'busy') { res.set('Retry-After', '10'); return fail(res, err.message, 503, 'busy'); }
+ if (err && (err.code === 'trace_timeout' || err.code === 'trace_failed' || err.code === 'trace_unavailable')) return fail(res, err.message, 502, err.code);
+ failFrom(res, err, `${what} failed`);
+ }
+ });
+ }
+
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
// Supertool Lookup (combines multiple tools)
// Supertool Lookup (combines multiple tools)
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Traceroute and MTR from this server, without raw sockets.
+//
+// Both run mtr (0.95+) in report mode with JSON output. Its mtr-packet helper sends ICMP echo through
+// Linux's unprivileged ICMP sockets (net.ipv4.ping_group_range), so it needs no capability and runs
+// under the unit's NoNewPrivileges. ICMP echo reaches hosts that ignore traceroute's classic UDP
+// probes (9.9.9.9 answers at hop 11; tracepath waited out all 30 hops), probes every hop at once, and
+// stops after 5 silent hops in a row (-U 5).
+//
+// The target is always the address the SSRF guard checked (never a name: no rebinding), passed in
+// argv to execFile (no shell). At most RUNNING_MAX runs at once across everyone; a caller over that
+// gets `busy` and tries again. The hops are the provider's own path; private hop addresses are shown
+// as mtr reports them (they are answers, never dialled).
+// ═══════════════════════════════════════════════════════════════
+
+const { execFile } = require('child_process');
+const net = require('net');
+
+const MTR_BIN = process.env.NET_MTR_BIN || '/usr/bin/mtr';
+const MAX_HOPS = 30;
+const MAX_UNKNOWN = 5;
+const RUNNING_MAX = 2;
+const MODES = {
+ // A quick path: three probes a hop.
+ trace: { cycles: 3, timeoutMs: 30000 },
+ // Loss and jitter per hop: ten probes a hop (about 11 s; non-root mtr cannot go under a second between rounds).
+ mtr: { cycles: 10, timeoutMs: 45000 },
+};
+
+/** Run the mtr binary: resolves { stdout } or rejects with a coded error. */
+function execMtr(args, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ execFile(MTR_BIN, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024, killSignal: 'SIGKILL', env: { PATH: '/usr/bin:/bin', LANG: 'C' } }, (err, stdout, stderr) => {
+ if (err) {
+ const e = new Error(err.killed ? 'The trace took too long and was stopped' : (String(stderr || err.message).trim().split('\n')[0] || 'mtr failed'));
+ e.code = err.killed ? 'trace_timeout' : err.code === 'ENOENT' ? 'trace_unavailable' : 'trace_failed';
+ return reject(e);
+ }
+ resolve({ stdout: String(stdout) });
+ });
+ });
+}
+
+const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 100) / 100 : null);
+
+/** mtr's JSON report → hops in order: { hop, ip, asn, loss, sent, last, avg, best, worst, stdev } (ip null = no reply). */
+function parseReport(stdout) {
+ let doc;
+ try { doc = JSON.parse(stdout); } catch { const e = new Error('mtr gave an answer that is not JSON'); e.code = 'trace_failed'; throw e; }
+ const hubs = (doc && doc.report && Array.isArray(doc.report.hubs)) ? doc.report.hubs : [];
+ return hubs.map((h) => {
+ const ip = typeof h.host === 'string' && net.isIP(h.host) ? h.host : null;
+ const asn = typeof h.ASN === 'string' && /^AS\d+$/.test(h.ASN) ? h.ASN : null;
+ return {
+ hop: Number(h.count) || 0, ip, asn,
+ loss: num(h['Loss%']), sent: Number(h.Snt) || 0,
+ last: ip ? num(h.Last) : null, avg: ip ? num(h.Avg) : null, best: ip ? num(h.Best) : null,
+ worst: ip ? num(h.Wrst) : null, stdev: ip ? num(h.StDev) : null,
+ };
+ }).filter((h) => h.hop > 0);
+}
+
+/**
+ * createTracer({ exec, reverse }) → { run(mode, dest) }. `exec(args, timeoutMs)` and `reverse(ip)` are
+ * for tests; `dest` is egress.resolve()'s answer ({ address, family }).
+ */
+function createTracer({ exec = execMtr, reverse = null, max = RUNNING_MAX } = {}) {
+ let running = 0;
+ const names = new Map(); // ip → PTR name (or '' for none), a small bounded cache
+
+ async function nameOf(ip) {
+ if (!reverse || !ip) return null;
+ if (names.has(ip)) return names.get(ip) || null;
+ let name = '';
+ try {
+ const got = await Promise.race([reverse(ip), new Promise((r) => setTimeout(() => r([]), 1500).unref())]);
+ name = Array.isArray(got) && typeof got[0] === 'string' ? got[0].slice(0, 253) : '';
+ } catch { name = ''; }
+ if (names.size > 2000) names.delete(names.keys().next().value);
+ names.set(ip, name);
+ return name || null;
+ }
+
+ async function run(mode, dest) {
+ const m = MODES[mode];
+ if (!m) throw new Error(`trace: unknown mode ${mode}`);
+ if (!dest || !net.isIP(dest.address)) { const e = new Error('No address to trace'); e.code = 'trace_failed'; throw e; }
+ if (running >= max) { const e = new Error('Other traces are running from this server right now; try again in a few seconds'); e.code = 'busy'; throw e; }
+ running++;
+ try {
+ const family = net.isIP(dest.address) === 6 ? '-6' : '-4';
+ const { stdout } = await exec([family, '-n', '-z', '-j', '-c', String(m.cycles), '-m', String(MAX_HOPS), '-U', String(MAX_UNKNOWN), '--', dest.address], m.timeoutMs);
+ const hops = parseReport(stdout);
+ await Promise.all(hops.map(async (h) => { h.host = await nameOf(h.ip); }));
+ const last = hops.length ? hops[hops.length - 1] : null;
+ return { hops, reached: !!(last && last.ip === dest.address), probes: m.cycles };
+ } finally { running--; }
+ }
+
+ return { run, get running() { return running; } };
+}
+
+module.exports = { createTracer, parseReport, MODES, MAX_HOPS };
@@ -17,7 +17,7 @@ const FAMILIES = [
@@ -17,7 +17,7 @@ const FAMILIES = [
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.',
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: [
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.'],
['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?', '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.'],
+ ['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 send ICMP echo hop by hop (mtr).'],
['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.'],
['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.'],
],
],
},
},
@@ -376,9 +376,9 @@ const NET = {
@@ -376,9 +376,9 @@ const NET = {
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.']] },
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.']] },
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.']] },
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 (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.']] },
+ 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: 'This test times TCP connections: 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. A web server answers it even when a firewall drops ICMP; for ICMP hop by hop, use the MTR report.', 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?', 'A timed TCP connection answers the same question — is the host up, how far away is it — and passes firewalls that drop ICMP. The traceroute and MTR tools use ICMP echo when you want the path.'], ['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 — See the Network Path to Any Host', desc: 'Trace the route from our server to any host: every hop with its name, network (AS number) and best, average and worst time. IPv4 and IPv6. No install.', kw: 'traceroute online, trace route, network path, tracert online, hop by hop latency, visual traceroute', about: 'A traceroute shows the routers between our server and the host, one hop at a time. Each hop is probed three times with ICMP echo (mtr), named by reverse DNS where it has a name, and tagged with the network (AS number) that runs it, so you can see where the path leaves one provider for the next and where the time goes.', bullets: ['Every hop with its name, address and network (AS number)', 'Best, average and worst time per hop, from three probes', 'IPv4, or IPv6 when the host has only an IPv6 address'], faq: [['How do I read a traceroute?', 'Look for the point where latency jumps sharply and stays high, or where replies stop for good. The hop before it usually names the network where the trouble starts.'], ['Why do some hops show no reply?', 'Many routers do not answer probes addressed to them, or answer only a few a second. A silent hop in the middle of a path that carries on is normal; replies that stop for good mean the path ends there or a firewall drops the probes.'], ['Where does the trace start?', 'At the OpenVibe server, outside your network. To see the path from your own connection, run traceroute (Windows: tracert) on your machine.']] },
+ mtr: { name: 'MTR Report', title: 'Online MTR Report — Packet Loss and Latency per Hop', desc: 'Run an MTR report from our server to any host: ten probes per hop with loss, last, best, average and worst time and jitter (StDev), plus each hop\'s network. IPv4 and IPv6.', kw: 'mtr online, mtr report, my traceroute, packet loss test, network path analysis, jitter test', about: 'MTR combines traceroute and ping: it sends ten ICMP echo probes to every hop on the path and reports, per hop, how many were lost and how the round-trip time varied. That separates a router that is merely slow to answer from a path that really loses traffic, which is the evidence a network operator asks for.', bullets: ['Loss, last, best, average and worst time and StDev for every hop', 'Each hop\'s name and network (AS number)', 'Ten probes a hop in about fifteen seconds, from the OpenVibe server'], 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.'], ['A middle hop shows loss but the destination shows none. Is something wrong?', 'Usually not. Routers limit how often they answer probes addressed to themselves while forwarding traffic normally. Loss that starts at a hop and carries on to every hop after it, including the destination, is real loss.']] },
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.']] },
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.']] },
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.']] },
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.']] },
@@ -332,7 +332,7 @@ async function fakeFetch(url) {
@@ -332,7 +332,7 @@ async function fakeFetch(url) {
const probe = await fetch(`${base}/api/net${t.endpoint}?target=`);
const probe = await fetch(`${base}/api/net${t.endpoint}?target=`);
assert.notStrictEqual(probe.status, 404, `${t.id} → ${t.endpoint} exists`);
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']);
+ assert.deepStrictEqual(NET_TOOLS.filter(t => t.status === 'unavailable').map(t => t.id), ['reputation']);
} finally {
} finally {
srv.closeAllConnections(); srv.close();
srv.closeAllConnections(); srv.close();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+// Traceroute and MTR (net/trace.js, /api/net/trace and /api/net/mtr): mtr's JSON report becomes hops with names and
+// networks; mtr is given only the address the SSRF guard checked, in argv after `--`; a private target, or a name
+// that resolves to one, is refused before mtr runs; two runs at once is the limit (busy, Retry-After); a timeout
+// is a 502 with its code. The resolver and mtr are fakes: nothing here touches a real network.
+const assert = require('assert');
+const express = require('express');
+const { createEgress } = require('../../_shared/egress');
+const createNetRoutes = require('../server/net/routes');
+const { createTracer, parseReport, MODES } = require('../server/net/trace');
+
+const DNS = { 'public.example': ['93.184.216.34'], 'lan.example': ['192.168.1.20'], 'six.example': [] };
+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 })));
+};
+const egress = createEgress({ lookup });
+
+const REPORT = (dst) => JSON.stringify({ report: { mtr: { dst, tests: 3 }, hubs: [
+ { count: 1, host: '15.204.72.1', ASN: 'AS16276', 'Loss%': 0, Snt: 3, Last: 0.17, Avg: 0.166, Best: 0.147, Wrst: 0.182, StDev: 0.018 },
+ { count: 2, host: '???', ASN: 'AS???', 'Loss%': 100, Snt: 3, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0 },
+ { count: 3, host: dst, ASN: 'AS15133', 'Loss%': 33.3333, Snt: 3, Last: 5.4, Avg: 5.4567, Best: 4.7, Wrst: 6.2, StDev: 0.7 },
+] } });
+
+(async () => {
+ // ── The report ──
+ const hops = parseReport(REPORT('93.184.216.34'));
+ assert.deepStrictEqual(hops.map(h => [h.hop, h.ip, h.asn]), [[1, '15.204.72.1', 'AS16276'], [2, null, null], [3, '93.184.216.34', 'AS15133']]);
+ assert.strictEqual(hops[2].avg, 5.46, 'times rounded to 0.01 ms');
+ assert.strictEqual(hops[2].loss, 33.33);
+ assert.strictEqual(hops[1].best, null, 'no reply has no times');
+ assert.throws(() => parseReport('not json'), (e) => e.code === 'trace_failed');
+
+ // ── The tracer: argv, names, reached, the limit ──
+ const calls = [];
+ let release = null;
+ const exec = async (args, timeoutMs) => {
+ calls.push({ args, timeoutMs });
+ if (release) await new Promise(r => { release.push(r); });
+ return { stdout: REPORT(args[args.length - 1]) };
+ };
+ const names = { '15.204.72.1': ['vl1.example-provider.net'] };
+ const tracer = createTracer({ exec, reverse: async (ip) => names[ip] || [] });
+ let run = await tracer.run('trace', { address: '93.184.216.34', family: 4 });
+ assert.deepStrictEqual(calls[0].args, ['-4', '-n', '-z', '-j', '-c', '3', '-m', '30', '-U', '5', '--', '93.184.216.34']);
+ assert.strictEqual(calls[0].timeoutMs, MODES.trace.timeoutMs);
+ assert.strictEqual(run.reached, true);
+ assert.strictEqual(run.probes, 3);
+ assert.strictEqual(run.hops[0].host, 'vl1.example-provider.net', 'a hop with a PTR name is named');
+ assert.strictEqual(run.hops[2].host, null);
+ run = await tracer.run('mtr', { address: '2606:4700:4700::1111', family: 6 });
+ assert.deepStrictEqual(calls[1].args.slice(0, 6), ['-6', '-n', '-z', '-j', '-c', '10']);
+ assert.strictEqual(calls[1].args[calls[1].args.length - 1], '2606:4700:4700::1111');
+ await assert.rejects(tracer.run('trace', { address: 'public.example' }), (e) => e.code === 'trace_failed', 'only an address is traced');
+
+ release = [];
+ const a = tracer.run('trace', { address: '93.184.216.34' });
+ const b = tracer.run('mtr', { address: '93.184.216.34' });
+ await assert.rejects(tracer.run('trace', { address: '93.184.216.34' }), (e) => e.code === 'busy', 'a third run at once is refused');
+ release.forEach(r => r()); await Promise.all([a, b]);
+ release = null;
+ assert.strictEqual(tracer.running, 0);
+
+ // ── The routes ──
+ const routeCalls = [];
+ const fakeTracer = {
+ async run(mode, dest) {
+ routeCalls.push([mode, dest.address]);
+ if (dest.address === '93.184.216.34' && mode === 'mtr' && routeCalls.length > 3) throw Object.assign(new Error('The trace took too long and was stopped'), { code: 'trace_timeout' });
+ return { hops: parseReport(REPORT(dest.address)), reached: true, probes: MODES[mode].cycles };
+ },
+ };
+ const app = express();
+ app.use('/api/net', createNetRoutes(null, null, { egress, tracer: fakeTracer, reverse: async () => [] }));
+ const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const get = async (p) => { const r = await fetch(`http://127.0.0.1:${srv.address().port}${p}`); return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json() }; };
+ try {
+ let r = await get('/api/net/trace?target=public.example');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.ip, '93.184.216.34');
+ assert.strictEqual(r.body.family, 4);
+ assert.strictEqual(r.body.hops.length, 3);
+ r = await get('/api/net/mtr/public.example');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.probes, 10);
+ assert.deepStrictEqual(routeCalls, [['trace', '93.184.216.34'], ['mtr', '93.184.216.34']], 'mtr gets the checked address, not the name');
+
+ for (const t of ['127.0.0.1', '10.0.0.8', '192.168.1.1', '169.254.169.254', 'lan.example', '[::1]', 'fd00::1']) {
+ r = await get(`/api/net/trace?target=${encodeURIComponent(t)}`);
+ assert.strictEqual(r.status, 403, `${t} is refused`);
+ }
+ assert.strictEqual(routeCalls.length, 2, 'nothing private reached mtr');
+ r = await get('/api/net/trace');
+ assert.strictEqual(r.status, 400);
+
+ const busyApp = express();
+ busyApp.use('/api/net', createNetRoutes(null, null, { egress, tracer: { run: async () => { throw Object.assign(new Error('busy'), { code: 'busy' }); } } }));
+ const s2 = await new Promise(res => { const s = busyApp.listen(0, '127.0.0.1', () => res(s)); });
+ const r2 = await fetch(`http://127.0.0.1:${s2.address().port}/api/net/mtr?target=public.example`);
+ assert.strictEqual(r2.status, 503);
+ assert.strictEqual(r2.headers.get('retry-after'), '10');
+ assert.strictEqual((await r2.json()).code, 'busy');
+ s2.close();
+
+ routeCalls.push(['x', 'x'], ['x', 'x']);
+ r = await get('/api/net/mtr?target=public.example');
+ assert.strictEqual(r.status, 502);
+ assert.strictEqual(r.body.code, 'trace_timeout');
+ } finally { srv.close(); }
+ console.log('net-trace: ok');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -109,9 +109,9 @@ function checkProblem(r, status, code, where) {
@@ -109,9 +109,9 @@ function checkProblem(r, status, code, where) {
checkProblem(await run('nosuchtool', {}), 404, 'tools.tool.not_found', 'unknown');
checkProblem(await run('nosuchtool', {}), 404, 'tools.tool.not_found', 'unknown');
checkProblem(await run('yt', {}), 404, 'tools.tool.not_runnable', 'yt: api false');
checkProblem(await run('yt', {}), 404, 'tools.tool.not_runnable', 'yt: api false');
checkProblem(await run('regex', { input: {} }), 404, 'tools.tool.not_runnable', 'regex: page only');
checkProblem(await run('regex', { input: {} }), 404, 'tools.tool.not_runnable', 'regex: page only');
- r = await run('traceroute', {});
+ r = await run('reputation', {});
checkProblem(r, 503, 'tools.tool.unavailable', 'descriptor status unavailable');
checkProblem(r, 503, 'tools.tool.unavailable', 'descriptor status unavailable');
- assert.match(r.body.detail, /raw network access/);
+ assert.match(r.body.detail, /not available yet/);
checkProblem(await run('protectpdf', { input: { password: 'x' } }), 503, 'tools.tool.unavailable', 'qpdf missing on docs');
checkProblem(await run('protectpdf', { input: { password: 'x' } }), 503, 'tools.tool.unavailable', 'qpdf missing on docs');
checkProblem(await call('/api/v1/tools/jsonminify/run', { method: 'GET' }), 405, 'method_not_allowed', 'GET');
checkProblem(await call('/api/v1/tools/jsonminify/run', { method: 'GET' }), 405, 'method_not_allowed', 'GET');
@@ -190,7 +190,7 @@ const get = async (url, headers = {}, method = 'GET') => {
@@ -190,7 +190,7 @@ const get = async (url, headers = {}, method = 'GET') => {
for (const id of ['protectpdf', 'unlockpdf', 'pdf2jpg', 'heic', 'mp3', 'merge', 'yt']) assert.strictEqual((await status(G, id)).status, 'unavailable', `gateway: ${id}`);
for (const id of ['protectpdf', 'unlockpdf', 'pdf2jpg', 'heic', 'mp3', 'merge', 'yt']) assert.strictEqual((await status(G, id)).status, 'unavailable', `gateway: ${id}`);
for (const id of ['png', 'mergepdf', 'fancy', 'dns']) assert.strictEqual((await status(G, id)).status, 'stable', `gateway: ${id}`);
for (const id of ['png', 'mergepdf', 'fancy', 'dns']) assert.strictEqual((await status(G, id)).status, 'stable', `gateway: ${id}`);
l = await count('status=unavailable');
l = await count('status=unavailable');
- assert.ok(l.tools.length >= 3 + 36 + 5 && contracts.tools.checkList(l).valid, 'status filter');
+ assert.ok(l.tools.length >= 1 + 36 + 5 && contracts.tools.checkList(l).valid, 'status filter'); // reputation (net), then the tools without binaries here
// Readiness: the registry is checked, and it passes.
// Readiness: the registry is checked, and it passes.
const ready = (await get(`${G}/api/ready`)).body;
const ready = (await get(`${G}/api/ready`)).body;