openvibers/openvibe.tools Commits

Net: the IPv4 and IPv6 pages do what they say, and every net tool has a renderer. The consistency test now also checks that net.html can render each tool's endpoint, which found two pages that showed raw JSON: IPv4 promised a CIDR subnet calculator but the route stripped the /nn (it now gives network, broadcast, netmask, wildcard, usable range and counts, /31 and /32 per RFC 3021, and names private, CGNAT, loopback, link-local, documentation, benchmarking, multicast and reserved ranges; private and reserved addresses get no upstream lookup); IPv6 promised expansion and compression but echoed the input (it now gives the full and the RFC 5952 compressed form, type, multicast scope and range, reverse DNS, and the owner of a global address). Both have renderers. whois goes back to the /rdap endpoint the page renders (the /whois alias route stays). Reverse DNS for IPv6 is injectable so the tests stay off the network

OP OpenVibers committed 23 Sept 2026 · d2c393111a · 5 files, +200 −76


Changes

ddiff --git a/apps/gateway/public/net.html b/apps/gateway/public/net.html
iindex df32e39..99b3a7a 100644
---- a/apps/gateway/public/net.html
++++ b/apps/gateway/public/net.html
@@@ -646,14 +646,14 @@
// IP / Identity
myip: { name: 'What Is My IP', icon: 'fa-location-crosshairs', cat: 'ip', endpoint: '/myip', placeholder: null, auto: true },
ip: { name: 'IP Address Lookup', icon: 'fa-at', cat: 'ip', endpoint: '/ip', placeholder: 'IP address or domain' },
-- ipv4: { name: 'IPv4 Lookup & CIDR Calculator', icon: 'fa-hashtag', cat: 'ip', endpoint: '/ipv4', placeholder: 'IPv4 address or domain' },
++ ipv4: { name: 'IPv4 Lookup & CIDR Calculator', icon: 'fa-hashtag', cat: 'ip', endpoint: '/ipv4', placeholder: 'IPv4 address, CIDR block (e.g. 192.168.1.0/24) or domain' },
ipv6: { name: 'IPv6 Lookup', icon: 'fa-code', cat: 'ip', endpoint: '/ipv6', placeholder: 'IPv6 address or domain' },
geoip: { name: 'IP Geolocation', icon: 'fa-earth-americas', cat: 'ip', endpoint: '/ip', placeholder: 'IP address or domain' },
hostname: { name: 'Hostname Lookup', icon: 'fa-server', cat: 'ip', endpoint: '/rdns', placeholder: 'IP address to resolve hostname' },
isp: { name: 'ISP Lookup', icon: 'fa-building', cat: 'ip', endpoint: '/ip', placeholder: 'IP address or domain' },
asn: { name: 'ASN Lookup', icon: 'fa-diagram-project', cat: 'ip', endpoint: '/ip', placeholder: 'IP address or domain' },
rdns: { name: 'Reverse DNS Lookup', icon: 'fa-rotate-left', cat: 'ip', endpoint: '/rdns', placeholder: 'IP address for reverse DNS' },
-- whois: { name: 'Whois Lookup', icon: 'fa-address-book', cat: 'ip', endpoint: '/whois', placeholder: 'Domain or IP' },
++ whois: { name: 'Whois Lookup', icon: 'fa-address-book', cat: 'ip', endpoint: '/rdap', placeholder: 'Domain or IP' },
rdap: { name: 'RDAP Lookup', icon: 'fa-id-card', cat: 'ip', endpoint: '/rdap', placeholder: 'Domain or IP for RDAP lookup' },
// DNS
dns: { name: 'DNS Lookup', icon: 'fa-sitemap', cat: 'dns', endpoint: '/dns', placeholder: 'Domain to query' },
@@@ -986,6 +986,8 @@
if (activeTool === 'myip') return renderMyIP(el, data);
if (activeTool === 'lookup' || isLookup) return renderLookup(el, data);
if (tool.endpoint === '/ip') return renderIP(el, data);
++ if (tool.endpoint === '/ipv4') return renderIPv4(el, data);
++ if (tool.endpoint === '/ipv6') return renderIPv6(el, data);
if (tool.endpoint === '/dns') return renderDNS(el, data);
if (tool.endpoint === '/rdns') return renderRDNS(el, data);
if (tool.endpoint === '/rdap') return renderRDAP(el, data);
@@@ -1291,6 +1293,47 @@
el.innerHTML = html;
}
++ // ── IPv4 (address or CIDR block) and IPv6 ────────────────
++ function renderIPv4(el, data) {
++ const v = data.ipv4 || {};
++ const c = data.cidr;
++ let html = `<div class="badges">
++ <span class="badge badge-cyan">${esc(c && c.prefix < 32 ? c.cidr : data.ip)}</span>
++ <span class="badge">Class ${esc(v.class)}</span>
++ <span class="badge ${v.isPublic ? 'badge-green' : 'badge-yellow'}">${esc(v.type)}</span>
++ </div>`;
++ if (c && c.prefix < 32) {
++ const n = (x) => Number(x).toLocaleString('en-US');
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-hashtag"></i> Subnet ${esc(c.cidr)}</div><table class="res-table">
++ ${row('Network', esc(c.network))}${c.broadcast ? row('Broadcast', esc(c.broadcast)) : ''}
++ ${row('Netmask', esc(c.netmask))}${row('Wildcard', esc(c.wildcard))}
++ ${row('Usable hosts', `${esc(c.firstUsable)} – ${esc(c.lastUsable)}`)}
++ ${row('Addresses', `${n(c.total)} total, ${n(c.usable)} usable`)}
++ </table></div>`;
++ }
++ if (data.geo) html += renderIPCard(data);
++ else if (data.note) html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.note)}</p>`;
++ el.innerHTML = html;
++ }
++
++ function renderIPv6(el, data) {
++ const v = data.ipv6 || {};
++ const nw = data.network;
++ let html = `<div class="badges">
++ <span class="badge badge-cyan">${esc(data.ip)}</span>
++ <span class="badge ${v.type === 'Global unicast' ? 'badge-green' : 'badge-yellow'}">${esc(v.type)}</span>
++ ${v.scope ? `<span class="badge">scope: ${esc(v.scope)}</span>` : ''}
++ </div>`;
++ html += `<div class="card"><div class="card-head"><i class="fa-solid fa-code"></i> IPv6 address</div><table class="res-table">
++ ${row('Compressed', esc(v.compressed))}${row('Expanded', esc(v.expanded))}
++ ${v.prefix ? row('Range', esc(v.prefix)) : ''}
++ ${row('Reverse DNS', v.ptr ? esc(v.ptr) : '<span style="color:var(--text-muted)">No PTR record</span>')}
++ ${nw ? row('Network', `${esc(nw.org || nw.isp || '—')}${nw.as ? ` (${esc(nw.as)})` : ''}`) : ''}
++ ${data.geo && data.geo.country ? row('Location', esc([data.geo.city, data.geo.country].filter(Boolean).join(', '))) : ''}
++ </table></div>`;
++ el.innerHTML = html;
++ }
++
// ── Website, mail and DNS checks ─────────────────────────
const row = (k, v) => `<tr><td class="key">${esc(k)}</td><td class="val">${v}</td></tr>`;
const from = (data) => data.checkedFrom ? `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Checked from ${esc(data.checkedFrom)}.</p>` : '';
ddiff --git a/apps/gateway/server/net/config.js b/apps/gateway/server/net/config.js
iindex 58c4336..bdc0673 100644
---- a/apps/gateway/server/net/config.js
++++ b/apps/gateway/server/net/config.js
@@@ -99,7 +99,7 @@ const NET_TOOLS = [
{ id: 'isp', subdomain: 'isp', name: 'OpenVibeISP', icon: 'fa-building', desc: 'ISP & organization info from IP', endpoint: '/ip' },
{ id: 'asn', subdomain: 'asn', name: 'OpenVibeASN', icon: 'fa-diagram-project', desc: 'Autonomous System Number lookup', endpoint: '/ip' },
{ id: 'rdns', subdomain: 'rdns', name: 'OpenVibeReverseDNS', icon: 'fa-rotate-left', desc: 'Reverse DNS (PTR) lookup for IPv4 & IPv6', endpoint: '/rdns' },
-- { id: 'whois', subdomain: 'whois', name: 'OpenVibeWhois', icon: 'fa-address-book', desc: 'Whois domain registration lookup', endpoint: '/whois' },
++ { id: 'whois', subdomain: 'whois', name: 'OpenVibeWhois', icon: 'fa-address-book', desc: 'Whois domain registration lookup', endpoint: '/rdap' },
{ id: 'rdap', subdomain: 'rdap', name: 'OpenVibeRDAP', icon: 'fa-id-card', desc: 'RDAP — modern registration data lookup', endpoint: '/rdap' },
// DNS
{ id: 'dns', subdomain: 'dns', name: 'OpenVibeDNS', icon: 'fa-sitemap', desc: 'DNS record lookup — A, AAAA, MX, TXT, and more', endpoint: '/dns' },
ddiff --git a/apps/gateway/server/net/routes.js b/apps/gateway/server/net/routes.js
iindex f641e69..36795da 100644
---- a/apps/gateway/server/net/routes.js
++++ b/apps/gateway/server/net/routes.js
@@@ -25,6 +25,89 @@ const IPAPI_FIELDS = 'status,message,country,countryCode,region,regionName,city,
* custom DNS server) goes through the shared SSRF guard: only public addresses, checked after DNS
* and dialled as checked. `opts.egress` is for tests (a guard with a mock resolver and transports).
*/
++// ── IPv4 / IPv6 arithmetic ─────────────────────────────────
++
++const ipv4ToInt = (ip) => ip.split('.').reduce((n, o) => n * 256 + Number(o), 0) >>> 0;
++const intToIpv4 = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
++
++/** a.b.c.d/prefix → the subnet: network, broadcast, mask, usable range and counts (RFC 3021 for /31). */
++function cidr4(ip, prefix) {
++ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
++ const network = (ipv4ToInt(ip) & mask) >>> 0;
++ const broadcast = (network | (~mask >>> 0)) >>> 0;
++ const total = 2 ** (32 - prefix);
++ const p2p = prefix >= 31;
++ return {
++ cidr: `${intToIpv4(network)}/${prefix}`, prefix,
++ network: intToIpv4(network), broadcast: p2p ? null : intToIpv4(broadcast),
++ netmask: intToIpv4(mask), wildcard: intToIpv4(~mask >>> 0),
++ firstUsable: intToIpv4(p2p ? network : network + 1), lastUsable: intToIpv4(p2p ? broadcast : broadcast - 1),
++ total, usable: p2p ? total : total - 2,
++ };
++}
++
++const V4_RANGES = [
++ ['0.0.0.0/8', 'This network (reserved)'], ['10.0.0.0/8', 'Private (RFC 1918)'], ['100.64.0.0/10', 'Carrier-grade NAT (RFC 6598)'],
++ ['127.0.0.0/8', 'Loopback'], ['169.254.0.0/16', 'Link-local'], ['172.16.0.0/12', 'Private (RFC 1918)'],
++ ['192.0.0.0/24', 'IETF protocol assignments'], ['192.0.2.0/24', 'Documentation (TEST-NET-1)'], ['192.168.0.0/16', 'Private (RFC 1918)'],
++ ['198.18.0.0/15', 'Benchmarking (RFC 2544)'], ['198.51.100.0/24', 'Documentation (TEST-NET-2)'], ['203.0.113.0/24', 'Documentation (TEST-NET-3)'],
++ ['224.0.0.0/4', 'Multicast'], ['255.255.255.255/32', 'Broadcast'], ['240.0.0.0/4', 'Reserved (class E)'],
++];
++
++function ipv4Info(ip) {
++ const first = Number(ip.split('.')[0]);
++ const [cls, range] = first < 128 ? ['A', '0.0.0.0 - 127.255.255.255'] : first < 192 ? ['B', '128.0.0.0 - 191.255.255.255']
++ : first < 224 ? ['C', '192.0.0.0 - 223.255.255.255'] : first < 240 ? ['D', '224.0.0.0 - 239.255.255.255'] : ['E', '240.0.0.0 - 255.255.255.255'];
++ const n = ipv4ToInt(ip);
++ const hit = V4_RANGES.find(([block]) => { const [base, p] = block.split('/'); const m = Number(p) === 0 ? 0 : (0xffffffff << (32 - Number(p))) >>> 0; return ((n & m) >>> 0) === ipv4ToInt(base); });
++ const type = hit ? hit[1] : 'Public';
++ return { class: cls, range, type, isPrivate: /Private|Carrier/.test(type), isLoopback: type === 'Loopback', isLinkLocal: type === 'Link-local', isPublic: type === 'Public' };
++}
++
++/** Any IPv6 text form (including an embedded IPv4 tail) → eight 4-digit groups. */
++function expand6(ip) {
++ let s = ip.toLowerCase().replace(/%.*$/, '');
++ const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
++ if (v4) { const n = ipv4ToInt(v4[1]); s = s.slice(0, -v4[1].length) + `${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`; }
++ const [head, tail] = s.split('::');
++ const left = head ? head.split(':') : [];
++ const right = tail !== undefined && tail ? tail.split(':') : [];
++ const groups = tail !== undefined ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right] : left;
++ return groups.map(g => g.padStart(4, '0')).join(':');
++}
++
++/** Eight groups → RFC 5952 text: no leading zeros, the longest run of two or more zero groups as ::. */
++function compress6(expanded) {
++ const groups = expanded.split(':').map(g => g.replace(/^0+(?=.)/, ''));
++ let best = [-1, 0];
++ for (let i = 0; i < 8;) {
++ if (groups[i] !== '0') { i++; continue; }
++ let j = i; while (j < 8 && groups[j] === '0') j++;
++ if (j - i > best[1] && j - i >= 2) best = [i, j - i];
++ i = j;
++ }
++ if (best[0] < 0) return groups.join(':');
++ return `${groups.slice(0, best[0]).join(':')}::${groups.slice(best[0] + best[1]).join(':')}`;
++}
++
++const V6_SCOPES = { 1: 'interface-local', 2: 'link-local', 4: 'admin-local', 5: 'site-local', 8: 'organization-local', 14: 'global' };
++function ipv6Kind(expanded) {
++ const g = expanded.split(':').map(x => parseInt(x, 16));
++ const zeroTo = (k) => g.slice(0, k).every(x => x === 0);
++ if (zeroTo(8)) return { type: 'Unspecified', scope: null, prefix: '::/128' };
++ if (zeroTo(7) && g[7] === 1) return { type: 'Loopback', scope: 'host', prefix: '::1/128' };
++ if (zeroTo(5) && g[5] === 0xffff) return { type: 'IPv4-mapped', scope: null, prefix: '::ffff:0:0/96' };
++ if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every(x => x === 0)) return { type: 'NAT64 (IPv4-translated)', scope: 'global', prefix: '64:ff9b::/96', global: true };
++ if ((g[0] & 0xffc0) === 0xfe80) return { type: 'Link-local', scope: 'link', prefix: 'fe80::/10' };
++ if ((g[0] & 0xfe00) === 0xfc00) return { type: 'Unique local (private)', scope: 'site', prefix: 'fc00::/7' };
++ if ((g[0] & 0xff00) === 0xff00) return { type: 'Multicast', scope: V6_SCOPES[g[0] & 0xf] || 'reserved', prefix: 'ff00::/8' };
++ if (g[0] === 0x2001 && g[1] === 0x0db8) return { type: 'Documentation', scope: null, prefix: '2001:db8::/32' };
++ if (g[0] === 0x2001 && g[1] === 0) return { type: 'Teredo', scope: 'global', prefix: '2001::/32', global: true };
++ if (g[0] === 0x2002) return { type: '6to4', scope: 'global', prefix: '2002::/16', global: true };
++ if ((g[0] & 0xe000) === 0x2000) return { type: 'Global unicast', scope: 'global', prefix: '2000::/3', global: true };
++ return { type: 'Reserved', scope: null, prefix: null };
++}
++
module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const router = express.Router();
const egress = opts.egress || createEgress();
@@@ -33,6 +116,7 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const fetchImpl = opts.fetch || fetch;
const cache = opts.cache || createCache({ max: 5000, ttlMs: HOUR });
const checks = createChecks({ egress, cache, ...(opts.resolverFor && { resolverFor: opts.resolverFor }), ...(opts.tlsUpgrade && { tlsUpgrade: opts.tlsUpgrade }) });
++ const reverseDns = opts.reverse || ((ip) => dns.reverse(ip));
// ── Helpers ──────────────────────────────────────────────
@@@ -222,11 +306,19 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
}
});
-- // IPv4 Lookup — IPv4-specific info
++ // IPv4 Lookup & CIDR calculator: a CIDR block (a.b.c.d/nn) gives the subnet; an address gives its
++ // class, what kind of range it is in, and (for public addresses) the owner and location.
router.get('/ipv4/:target?', async (req, res) => {
try {
-- const target = parseTarget(req.params.target || req.query.target);
-- if (!target) return fail(res, 'Please provide a valid IPv4 address or domain');
++ const rawTarget = String(req.params.target || req.query.target || '').trim();
++ const block = /^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/.exec(rawTarget);
++ if (block) {
++ const prefix = Number(block[2]);
++ if (net.isIP(block[1]) !== 4 || prefix > 32) return fail(res, 'Enter a CIDR block like 192.168.1.0/24');
++ return ok(res, { ip: block[1], cidr: cidr4(block[1], prefix), ipv4: ipv4Info(block[1]) });
++ }
++ const target = parseTarget(rawTarget);
++ if (!target) return fail(res, 'Please provide a valid IPv4 address, CIDR block or domain');
let ip = target;
let hostname = null;
@@@ -236,56 +328,22 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
ip = addrs[0];
hostname = target;
}
--
-- // Validate it's IPv4
if (net.isIP(ip) !== 4) return fail(res, `${ip} is not a valid IPv4 address`);
-- // Determine IP class
-- const parts = ip.split('.').map(Number);
-- let ipClass, range;
-- if (parts[0] < 128) { ipClass = 'A'; range = '1.0.0.0 - 126.255.255.255'; }
-- else if (parts[0] < 192) { ipClass = 'B'; range = '128.0.0.0 - 191.255.255.255'; }
-- else if (parts[0] < 224) { ipClass = 'C'; range = '192.0.0.0 - 223.255.255.255'; }
-- else if (parts[0] < 240) { ipClass = 'D'; range = '224.0.0.0 - 239.255.255.255'; }
-- else { ipClass = 'E'; range = '240.0.0.0 - 255.255.255.255'; }
--
-- // Check if private
-- const isPrivate = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(ip);
-- const isLoopback = ip.startsWith('127.');
-- const isLinkLocal = ip.startsWith('169.254.');
--
-- // GeoIP (ipinfo with a token, else ip-api.com; cached)
-- const data = await geoLookup(ip);
--
-- if (data.status === 'fail') return fail(res, data.message || 'Lookup failed');
++ const out = { ip, hostname, ipv4: ipv4Info(ip), cidr: cidr4(ip, 32) };
++ // Private and reserved addresses have no public owner or location.
++ if (!egress.isAllowed(ip)) return ok(res, { ...out, note: 'A private or reserved address: it has no public owner or location.' });
++ const data = await geoLookup(ip); // ipinfo with a token, else ip-api.com; cached
++ if (data.status === 'fail') return ok(res, { ...out, note: data.message || 'No owner information' });
ok(res, {
-- ip: data.query || ip,
++ ...out,
hostname: hostname || data.reverse || null,
-- ipv4: {
-- class: ipClass,
-- range: range,
-- isPrivate: isPrivate,
-- isLoopback: isLoopback,
-- isLinkLocal: isLinkLocal,
-- },
geo: {
-- country: data.country,
-- countryCode: data.countryCode,
-- region: data.regionName,
-- regionCode: data.region,
-- city: data.city,
-- zip: data.zip,
-- lat: data.lat,
-- lon: data.lon,
-- timezone: data.timezone,
-- },
-- network: {
-- isp: data.isp,
-- org: data.org,
-- as: data.as,
-- asname: data.asname,
++ country: data.country, countryCode: data.countryCode, region: data.regionName, regionCode: data.region,
++ city: data.city, zip: data.zip, lat: data.lat, lon: data.lon, timezone: data.timezone,
},
++ network: { isp: data.isp, org: data.org, as: data.as, asname: data.asname },
reverse: data.reverse || null,
});
} catch (err) {
@@@ -293,7 +351,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
}
});
-- // IPv6 Lookup — IPv6-specific info
++ // IPv6 Lookup: full and compressed notation, what kind of address it is, reverse DNS, and the owner
++ // of a global address.
router.get('/ipv6/:target?', async (req, res) => {
try {
const target = parseTarget(req.params.target || req.query.target);
@@@ -307,32 +366,23 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
ip = addrs[0];
hostname = target;
}
--
-- // Validate it's IPv6
if (net.isIP(ip) !== 6) return fail(res, `${ip} is not a valid IPv6 address`);
-- // Determine IPv6 type
-- let ipv6Type = 'Global Unicast';
-- if (ip.startsWith('::1')) ipv6Type = 'Loopback';
-- else if (ip.startsWith('::')) ipv6Type = 'Loopback/Unspecified';
-- else if (ip.startsWith('fe80:')) ipv6Type = 'Link-Local';
-- else if (ip.startsWith('ff')) ipv6Type = 'Multicast';
-- else if (ip.startsWith('fc') || ip.startsWith('fd')) ipv6Type = 'Unique Local (Private)';
-- else if (ip.startsWith('2001:db8:')) ipv6Type = 'Documentation';
--
-- // Reverse DNS lookup
-- const hostnames = await dns.reverse(ip).catch(() => []);
--
-- ok(res, {
-- ip: ip,
-- hostname: hostname || hostnames[0] || null,
-- ipv6: {
-- type: ipv6Type,
-- compressed: ip,
-- ptr: hostnames[0] || null,
-- },
-- note: 'Most public IPv6 addresses do not have geolocation data available through standard APIs.',
-- });
++ const expanded = expand6(ip);
++ const kind = ipv6Kind(expanded);
++ const hostnames = await reverseDns(ip).catch(() => []);
++ const out = {
++ ip: compress6(expanded), hostname: hostname || hostnames[0] || null,
++ ipv6: { type: kind.type, scope: kind.scope, prefix: kind.prefix, expanded, compressed: compress6(expanded), ptr: hostnames[0] || null },
++ };
++ if (kind.global && egress.isAllowed(ip)) {
++ const data = await geoLookup(ip).catch(() => null);
++ if (data && data.status === 'success') {
++ out.network = { isp: data.isp, org: data.org, as: data.as, asname: data.asname };
++ out.geo = { country: data.country, countryCode: data.countryCode, region: data.regionName, city: data.city, timezone: data.timezone };
++ }
++ }
++ ok(res, out);
} catch (err) {
fail(res, err.message || 'IPv6 lookup failed', 500);
}
@@@ -862,3 +912,5 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
return router;
};
++
++module.exports.helpers = { cidr4, ipv4Info, expand6, compress6, ipv6Kind };
ddiff --git a/apps/gateway/test/net-tools.test.js b/apps/gateway/test/net-tools.test.js
iindex db314d0..91b8e50 100644
---- a/apps/gateway/test/net-tools.test.js
++++ b/apps/gateway/test/net-tools.test.js
@@@ -183,7 +183,7 @@ async function fakeFetch(url) {
const egress = createEgress({ lookup, tcpConnect, tlsConnect: () => { throw new Error('unused'); }, httpRequest: fakeHttp, httpsRequest: fakeHttp });
const app = express();
app.set('trust proxy', 1); // what the gateway sets: the host's nginx is the one hop
-- app.use('/api/net', createNetRoutes(null, null, { egress, resolverFor, tlsUpgrade, fetch: fakeFetch }));
++ app.use('/api/net', createNetRoutes(null, null, { egress, resolverFor, tlsUpgrade, fetch: fakeFetch, reverse: async (ip) => (ip === '2606:4700:4700::1111' ? ['one.one.one.one'] : []) }));
const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
const base = `http://127.0.0.1:${srv.address().port}`;
const get = async (p, headers) => { const r = await fetch(base + p, { headers }); return { status: r.status, body: await r.json() }; };
@@@ -217,6 +217,31 @@ async function fakeFetch(url) {
assert.ok(fetched.some(u => u.startsWith('https://ipinfo.io/8.8.4.4/json?token=tok_test')), 'ipinfo over HTTPS with the token');
assert.deepStrictEqual([r.body.geo.city, r.body.network.isp, r.body.network.as, r.body.reverse], ['Mountain View', 'Google LLC', 'AS15169 Google LLC', 'dns.google']);
++ // IPv4: CIDR blocks are calculated; private addresses are classified without an upstream call.
++ const before = fetched.length;
++ r = await get(`/api/net/ipv4?target=${q('192.168.1.77/24')}`);
++ assert.deepStrictEqual(r.body.cidr, { cidr: '192.168.1.0/24', prefix: 24, network: '192.168.1.0', broadcast: '192.168.1.255', netmask: '255.255.255.0', wildcard: '0.0.0.255', firstUsable: '192.168.1.1', lastUsable: '192.168.1.254', total: 256, usable: 254 });
++ assert.strictEqual(r.body.ipv4.type, 'Private (RFC 1918)');
++ r = await get(`/api/net/ipv4?target=${q('10.0.0.0/31')}`);
++ assert.deepStrictEqual([r.body.cidr.usable, r.body.cidr.broadcast, r.body.cidr.lastUsable], [2, null, '10.0.0.1'], '/31 is point-to-point');
++ r = await get('/api/net/ipv4?target=100.64.3.4');
++ assert.deepStrictEqual([r.body.ipv4.type, r.body.cidr.total, !!r.body.note], ['Carrier-grade NAT (RFC 6598)', 1, true]);
++ assert.strictEqual(fetched.length, before, 'no geolocation lookup for ranges or private addresses');
++ r = await get('/api/net/ipv4?target=8.8.8.8');
++ assert.deepStrictEqual([r.body.ipv4.type, r.body.geo.city], ['Public', 'Mountain View']);
++ assert.strictEqual((await get(`/api/net/ipv4?target=${q('1.2.3.4/40')}`)).status, 400);
++ // IPv6: expanded and compressed forms, type and scope, owner for global addresses.
++ r = await get(`/api/net/ipv6?target=${q('2001:DB8:0:0:1::1')}`);
++ assert.deepStrictEqual([r.body.ipv6.expanded, r.body.ipv6.compressed, r.body.ipv6.type], ['2001:0db8:0000:0000:0001:0000:0000:0001', '2001:db8::1:0:0:1', 'Documentation']);
++ r = await get(`/api/net/ipv6?target=${q('ff02::1')}`);
++ assert.deepStrictEqual([r.body.ipv6.type, r.body.ipv6.scope], ['Multicast', 'link-local']);
++ r = await get(`/api/net/ipv6?target=${q('2606:4700:4700::1111')}`);
++ assert.deepStrictEqual([r.body.ipv6.type, r.body.network.isp, r.body.ipv6.ptr], ['Global unicast', 'Google LLC', 'one.one.one.one'], 'a global address gets its owner (mock answer)');
++ const { expand6, compress6 } = createNetRoutes.helpers;
++ assert.strictEqual(expand6('::ffff:192.0.2.1'), '0000:0000:0000:0000:0000:ffff:c000:0201');
++ assert.strictEqual(compress6(expand6('2001:db8:0:0:1:0:0:1')), '2001:db8::1:0:0:1', 'the first of two equal zero runs');
++ assert.strictEqual(compress6(expand6('1:0:2:3:4:5:6:7')), '1:0:2:3:4:5:6:7', 'a single zero group is not ::');
++
// robots
r = await get(`/api/net/robots?target=${q('https://site.example/some/page')}&path=${q('/private/x')}`);
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
ddiff --git a/apps/gateway/test/registry-consistency.test.js b/apps/gateway/test/registry-consistency.test.js
iindex 9f4d760..3ba0ff6 100644
---- a/apps/gateway/test/registry-consistency.test.js
++++ b/apps/gateway/test/registry-consistency.test.js
@@@ -58,7 +58,7 @@ function netEndpoints() {
return new Set(router.stack.filter(l => l.route).map(l => l.route.path.replace(/\/:target\?$/, '')));
}
--/** net.html's TOOLS: id → { endpoint, unavailable }. */
++/** net.html's TOOLS: id → { endpoint, unavailable }, plus the endpoints renderResults() has a renderer for. */
function netPage() {
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'net.html'), 'utf8');
const block = html.slice(html.indexOf('const TOOLS = {'), html.indexOf('const CATS = {'));
@@@ -67,6 +67,9 @@ function netPage() {
const ep = /endpoint: (?:null|'([^']*)')/.exec(m[0]);
out.set(m[1], { endpoint: ep ? ep[1] || null : undefined, unavailable: /unavailable: '/.test(m[0]) });
}
++ const dispatch = html.slice(html.indexOf('function renderResults('), html.indexOf('// Fallback: raw JSON'));
++ out.rendered = new Set([...dispatch.matchAll(/tool\.endpoint === '([^']+)'\) return render/g)].map(m => m[1]));
++ out.renderedById = new Set([...dispatch.matchAll(/activeTool === '([a-z0-9]+)'/g)].map(m => m[1]));
return out;
}
@@@ -103,6 +106,7 @@ for (const t of cat.tools) {
}
if (!def.endpoint || !endpoints.has(def.endpoint)) problems.push(`net/${t.id}: endpoint ${def.endpoint} is not a route of /api/net`);
if (ui.endpoint !== def.endpoint) problems.push(`net/${t.id}: net.html calls ${ui.endpoint}, net/config.js says ${def.endpoint}`);
++ if (!page.rendered.has(ui.endpoint) && !page.renderedById.has(t.id)) problems.push(`net/${t.id}: net.html has no renderer for ${ui.endpoint} (the result would show as raw JSON)`);
if (t.status !== 'available') problems.push(`net/${t.id}: implemented but marked ${t.status}`);
break;
}