diff --git a/changes/feat-ipinfo-link.md b/changes/feat-ipinfo-link.md
new file mode 100644
index 000000000..42e71ef62
--- /dev/null
+++ b/changes/feat-ipinfo-link.md
@@ -0,0 +1,3 @@
+Added
+
+- Choose an IP lookup site from public destination and DNS answer addresses. DNS answers now lead with the address, followed by the type and TTL.
diff --git a/e2e/connections.spec.ts b/e2e/connections.spec.ts
index 0ea2a94cb..596a3fe37 100644
--- a/e2e/connections.spec.ts
+++ b/e2e/connections.spec.ts
@@ -859,3 +859,48 @@ test('a failed connection read is shown on both tabs with a retry', async ({page
await alert.getByRole('button', {name: 'Retry'}).click();
await expect(alert).toHaveCount(0);
});
+
+test('only public destination addresses open the IP site picker without background requests', async ({page}) => {
+ const {api, handlers} = await mockBackend(page);
+ const list = await api.connections({detail: 'full', limit: 1000});
+ const row = list.tcp[0];
+ const external: string[] = [];
+ page.on('request', request => {
+ if (['ipinfo.io', 'ipapi.is', 'ip-api.com', 'bgp.he.net'].includes(new URL(request.url()).hostname)) external.push(request.url());
+ });
+ for (const [dst, address, ip] of [
+ ['192.0.2.1:443', '192.0.2.1:443', '192.0.2.1'],
+ ['[2001:0DB8:0:0::1]:443', '[2001:0DB8:0:0::1]:443', '2001:db8::1'],
+ ['10.0.0.1:443', null, null],
+ [undefined, null, null]
+ ]) {
+ handlers['GET connections'] = async () => ({...list, tcp: [{...row, dst: dst ?? undefined}], udp: [], truncated: false});
+ await page.goto(`/#/connections?id=${row.id}`);
+ await page.reload();
+ const panel = page.locator('.rp-panel');
+ await expect(panel).toBeVisible();
+ const destination = panel.locator('.rp-kv > div').filter({has: page.locator('.k', {hasText: /^Target address$/})});
+ await expect(destination).toContainText(dst ?? '—');
+ expect(external).toEqual([]);
+ if (address && ip) {
+ await destination.getByRole('button', {name: address, exact: true}).click();
+ const menu = page.getByRole('menu').filter({hasText: `Look up ${ip}`});
+ await expect(menu).toBeVisible();
+ for (const [name, href] of [
+ ['ipinfo.io', `https://ipinfo.io/${ip}`],
+ ['ipapi.is', `https://ipapi.is/?q=${ip}`],
+ ['ip-api.com', `https://ip-api.com/#${ip}`],
+ ['bgp.he.net', `https://bgp.he.net/ip/${ip}`]
+ ]) {
+ const link = menu.getByRole('menuitem', {name, exact: true});
+ await expect(link).toHaveAttribute('href', href);
+ await expect(link).toHaveAttribute('target', '_blank');
+ await expect(link).toHaveAttribute('rel', /noreferrer/);
+ }
+ await page.keyboard.press('Escape');
+ await expect(menu).toHaveCount(0);
+ await expect(destination.getByRole('button', {name: address, exact: true})).toBeFocused();
+ } else await expect(destination.getByRole('button')).toHaveCount(0);
+ }
+ expect(external).toEqual([]);
+});
diff --git a/e2e/dns.spec.ts b/e2e/dns.spec.ts
index f0a2cf3b9..2db0ba7cf 100644
--- a/e2e/dns.spec.ts
+++ b/e2e/dns.spec.ts
@@ -629,3 +629,70 @@ test('the cache card meters its usage against the entry capacity', async ({page}
await expect(meter).toHaveAttribute('aria-valuetext', /^\d+%$/);
await expect(meter.locator('.v')).toHaveText((await meter.getAttribute('aria-valuetext'))!);
});
+
+test('public DNS answers open the IP site picker in query cards and record details', async ({page}) => {
+ const {api, handlers} = await mockBackend(page);
+ const result = await api.dnsQuery('example.com', ['A']);
+ const answers = [
+ ['A', '192.0.2.1'],
+ ['A', '10.0.0.1'],
+ ['CNAME', '8.8.8.8'],
+ ['AAAA', '2001:0DB8:0:0::1']
+ ].map(([type, data]) => ({...result.results[0].answers![0], type, data}));
+ handlers['POST dns/query'] = async () => ({...result, results: [{...result.results[0], answers}]});
+ const log = await api.dnsLog();
+ handlers['GET dns/log'] = async () => ({...log, records: [{...log.records[0], answers}], total: 1, next_cursor: null});
+ const external: string[] = [];
+ page.on('request', request => {
+ if (['ipinfo.io', 'ipapi.is', 'ip-api.com', 'bgp.he.net'].includes(new URL(request.url()).hostname)) external.push(request.url());
+ });
+ await page.goto('/#/dns?tab=query&domain=example.com&type=A');
+ await page.getByRole('button', {name: 'Query', exact: true}).click();
+ const queryPanel = page.getByRole('tabpanel', {name: 'Query', exact: true});
+ for (const [button, ip] of [
+ ['192.0.2.1', '192.0.2.1'],
+ ['2001:0DB8:0:0::1', '2001:db8::1']
+ ]) {
+ await expect(queryPanel.locator('button.rp-link')).toHaveCount(2);
+ expect(external).toEqual([]);
+ const trigger = queryPanel.getByRole('button', {name: button, exact: true});
+ await expect(trigger).toHaveCSS('border-top-width', '0px');
+ await expect(trigger).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+ await trigger.click();
+ const menu = page.getByRole('menu').filter({hasText: `Look up ${ip}`});
+ await expect(menu).toBeVisible();
+ for (const [name, href] of [
+ ['ipinfo.io', `https://ipinfo.io/${ip}`],
+ ['ipapi.is', `https://ipapi.is/?q=${ip}`],
+ ['ip-api.com', `https://ip-api.com/#${ip}`],
+ ['bgp.he.net', `https://bgp.he.net/ip/${ip}`]
+ ]) {
+ const link = menu.getByRole('menuitem', {name, exact: true});
+ await expect(link).toHaveAttribute('href', href);
+ await expect(link).toHaveAttribute('target', '_blank');
+ await expect(link).toHaveAttribute('rel', /noreferrer/);
+ }
+ await page.keyboard.press('Escape');
+ await expect(menu).toHaveCount(0);
+ await expect(queryPanel.getByRole('button', {name: button, exact: true})).toBeFocused();
+ }
+ await page.goto('/#/dns?tab=log');
+ await page.getByRole('grid', {name: 'Resolution log', exact: true}).getByRole('rowheader').first().click();
+ const detail = page.locator('.rp-panel');
+ await expect(detail.locator('button.rp-link')).toHaveCount(2);
+ expect(external).toEqual([]);
+ await detail.getByRole('button', {name: '192.0.2.1', exact: true}).click();
+ const menu = page.getByRole('menu').filter({hasText: 'Look up 192.0.2.1'});
+ await expect(menu).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(menu).toHaveCount(0);
+ expect(external).toEqual([]);
+ await page.context().route('https://ipapi.is/**', route => route.fulfill({contentType: 'text/html', body: '
IP lookup'}));
+ await detail.getByRole('button', {name: '192.0.2.1', exact: true}).click();
+ const popup = page.waitForEvent('popup');
+ await menu.getByRole('menuitem', {name: 'ipapi.is', exact: true}).click();
+ const opened = await popup;
+ await expect(opened).toHaveURL('https://ipapi.is/?q=192.0.2.1');
+ await expect(menu).toHaveCount(0);
+ await opened.close();
+});
diff --git a/src/features/connections/Connections.tsx b/src/features/connections/Connections.tsx
index 21c09e04a..da2b0ce9b 100644
--- a/src/features/connections/Connections.tsx
+++ b/src/features/connections/Connections.tsx
@@ -1,3 +1,5 @@
+import {IpLookup} from '../../ui/IpLookup';
+import {ipLookupSites} from '../shared/ipLookup';
import {OutboundTag} from '../shared/OutboundTag';
import {
Badge,
@@ -144,7 +146,12 @@ export function Connections(props: PageProps) {
]}
/>
-
+ [
+ label,
+ typeof value === 'string' ? value :
+ ])}
+ />
],
diff --git a/src/features/connections/view.test.ts b/src/features/connections/view.test.ts
index 1c7516bf3..16f0fc276 100644
--- a/src/features/connections/view.test.ts
+++ b/src/features/connections/view.test.ts
@@ -88,3 +88,14 @@ it('explains the states before a connection is established, and no other', () =>
]);
expect(connectionStateHelp('active', t)).toBeNull();
});
+
+it.each([
+ ['192.0.2.1:443', {ip: '192.0.2.1', address: '192.0.2.1:443'}],
+ ['[2001:0DB8:0:0::1]:443', {ip: '2001:db8::1', address: '[2001:0DB8:0:0::1]:443'}],
+ ['2001:db8::1', {ip: '2001:db8::1', address: '2001:db8::1'}],
+ ['10.0.0.1:443', '10.0.0.1:443'],
+ [undefined, '—']
+])('prepares a lookup only in the public destination field: %s', (dst, value) => {
+ const detail = connectionDetail({...connections.tcp[0], network: 'tcp', dst}, 'en-US', t, new Map(), false);
+ expect(detail?.fields.find(([label]) => label === t('conn.f.dst'))?.[1]).toEqual(value);
+});
diff --git a/src/features/connections/view.ts b/src/features/connections/view.ts
index d912917fa..283ae2114 100644
--- a/src/features/connections/view.ts
+++ b/src/features/connections/view.ts
@@ -2,6 +2,7 @@ import type {BulkCloseQuery, Connection} from '../../api/model';
import {tagId} from '../shared/taggedId';
import {enumLabel} from '../../i18n/enum';
import {chainLabel, chainNames, connectionStates, sourceIp, addressPort, type MessageRef, type OutboundNames} from '../../api/selectors';
+import {isPublicIp} from '../shared/ipLookup';
import {localTime, formatBytes, formatRate} from '../../i18n/format';
import type {Key, Translator as LabelFn} from '../../i18n';
import {word} from '../../api/labels';
@@ -18,14 +19,18 @@ const chainSources: Record = {
reconstructed: 'conn.chainSource.reconstructed',
unknown: 'ui.unknown'
};
-export function connectionDetails(c: Connection, locale: string): Array<[Key, string | MessageRef]> {
+type Destination = {ip: string; address: string};
+export function connectionDetails(c: Connection, locale: string): Array<[Key, string | MessageRef | Destination]> {
+ const address = c.dst ?? '—';
+ const ip = sourceIp(c.dst);
+ const destination = ip && isPublicIp(ip) ? {ip, address} : address;
// A backend that predates the field leaves it out, and the row with it.
const chainSource: Array<[Key, string | MessageRef]> = c.chain_source
? [['conn.f.chainSource', chainSources[c.chain_source] ? {key: chainSources[c.chain_source]} : c.chain_source]]
: [];
return [
['ui.device', c.src ?? '—'],
- ['conn.f.dst', c.dst ?? '—'],
+ ['conn.f.dst', destination],
['ui.domain', c.domain ?? '—'],
['conn.f.ingress', word(c.ingress)],
['conn.f.domainSource', word(c.domain_source)],
@@ -106,7 +111,7 @@ export function connectionDetail(
outbound: current.outbound,
rule: {expression: current.rule_expression, href: ruleHref(current.rule_id, rulesListed)},
fields: connectionDetails(current, locale).map(
- ([key, value]) => [t(key), typeof value === 'string' ? value : t(value.key, value.params)] as [string, string]
+ ([key, value]) => [t(key), typeof value === 'string' || 'ip' in value ? value : t(value.key, value.params)] as [string, string | Destination]
),
flowQuery: within('', {tab: 'records', ...(current.flow_id ? {id: current.flow_id} : {connection_id: current.id})}),
// The trace of this connection's target, from its source and process; null when it has neither a domain nor an
diff --git a/src/features/dns/Dns.tsx b/src/features/dns/Dns.tsx
index 86c920155..6c3535540 100644
--- a/src/features/dns/Dns.tsx
+++ b/src/features/dns/Dns.tsx
@@ -1,8 +1,10 @@
-import {useMemo} from 'react';
+import {useMemo, type ReactNode} from 'react';
import {useT} from '../../i18n';
import Delete from '../../ui/icons/Delete';
import Download from '../../ui/icons/Download';
import Refresh from '../../ui/icons/Refresh';
+import {IpLookup} from '../../ui/IpLookup';
+import {ipLookupSites} from '../shared/ipLookup';
import {
ActionGroup,
ActionHelp,
@@ -33,12 +35,34 @@ import type {PageProps} from '../../shell/routes';
import {useDns, useDnsCacheTab, useDnsLogTab, type DnsRowRule} from './useDns';
import {DnsStats} from './Analysis';
import type {MatchKind} from './match';
+import type {dnsAnswerView} from './view';
import {QuickRuleButton, RuleDialog} from '../shared/RuleDialog';
import '../../ui/styles/impact.css';
+import '../../ui/styles/dns-answer.css';
type DnsCacheRow = ReturnType['rows'][number];
type DnsLogRow = ReturnType['rows'][number];
+type DnsAnswerRow = ReturnType['answers'][number];
+
+// One answer: the record data first, so an address reads at a glance, then its owner when it differs from the name
+// asked, its type and TTL. A public address opens the lookup sites.
+function DnsAnswer({answer, children}: {answer: DnsAnswerRow; children?: ReactNode}) {
+ return (
+
+
+ {answer.lookup ? : answer.data}
+
+
+ {answer.name && {answer.name}}
+ {answer.type}
+ {answer.ttl}
+
+ {children}
+
+ );
+}
+
export function Dns(props: PageProps) {
const t = useT();
const vm = useDns(props);
@@ -94,14 +118,13 @@ export function Dns(props: PageProps) {
{card.answers.map((answer, i) => {
const address = card.answerSeeds[i];
return (
-
-
{answer}
+
{address && vm.rule.canAdd(address.seed) && (
)}
-
+
);
})}
@@ -362,9 +385,7 @@ function DnsLog({
{vm.detail.answers.length ? (
{vm.detail.answers.map((answer, i) => (
-
- {answer}
-
+
))}
) : (
diff --git a/src/features/dns/view.test.ts b/src/features/dns/view.test.ts
index c055f41c8..4d39114be 100644
--- a/src/features/dns/view.test.ts
+++ b/src/features/dns/view.test.ts
@@ -30,7 +30,7 @@ it('uses the same nullable field and answer projection for queries and log detai
expect(query.cards[0].answers).toEqual(dnsLogDetail(data, record.id, 'en-US', t)?.answers);
expect(query.cards[0].fields).toContainEqual([t('ui.upstream'), '—']);
expect(query.cards[0].fields).toContainEqual([t('ui.elapsed'), t('ui.latency', {n: '0'})]);
- expect(dnsAnswerView({...record, answers: undefined}, t).answers).toEqual([]);
+ expect(dnsAnswerView({...record, answers: undefined}, record.question.name, t).answers).toEqual([]);
expect(log.rows[0]).toMatchObject({source: '—', result: '192.0.2.1', cached: true});
expect(dnsLogsExport([record])).toContain('192.0.2.1');
});
@@ -139,7 +139,10 @@ it('holds the loaded window when a poll advances the head after the final older
});
it('names the route source and the cache entry a delete button removes', () => {
- expect(dnsAnswerView({...record, route: {source: 'dns.routing', rule: 'r1'}}, t).fields).toContainEqual([t('dns.routeSource'), t('dns.route.rules')]);
+ expect(dnsAnswerView({...record, route: {source: 'dns.routing', rule: 'r1'}}, record.question.name, t).fields).toContainEqual([
+ t('dns.routeSource'),
+ t('dns.route.rules')
+ ]);
const entry = dnsCacheView(dnsCache, capabilities.resources, null, 'en-US', t).rows[0];
expect(entry.deleteLabel).toBe(t('dns.deleteEntry', {domain: entry.domain, type: entry.type}));
expect(entry.deleteLabel).not.toContain(entry.id);
@@ -212,3 +215,29 @@ it.each([
const resources = {...capabilities.resources, dns_cache: {...capabilities.resources.dns_cache, available, delete_name, delete_entry}};
expect(dnsCacheView(dnsCache, resources, null, 'en', t).deleteBy).toEqual(by);
});
+
+it('leads each answer with its data, links public addresses and names the owner only down a chain', () => {
+ const answers = [
+ ['A', '192.0.2.1', 'EXAMPLE.com'],
+ ['A', '10.0.0.1', 'example.com.'],
+ ['CNAME', '8.8.8.8', 'example.com.'],
+ ['AAAA', '2001:0DB8:0:0::1', 'edge.example.net.']
+ ].map(([type, data, name]) => ({...record.answers[0], type, data, name, ttl: 60}));
+ const item = {...record, question: {...record.question, name: 'example.com.'}, answers};
+ const result: DnsQueryResponse = {
+ domain: 'example.com',
+ cache_mode: 'normal',
+ query_time: record.observed_at,
+ results: [{...item, type: 'A', cache_entry_id: null}]
+ };
+ const data = {observed_at: record.observed_at, total: 1, next_cursor: null, records: [item]};
+ const ttl = t('dns.ttl', {ttl: 60});
+ const expected = [
+ {data: '192.0.2.1', name: null, type: 'A', ttl, lookup: '192.0.2.1'},
+ {data: '10.0.0.1', name: null, type: 'A', ttl, lookup: null},
+ {data: '8.8.8.8', name: null, type: 'CNAME', ttl, lookup: null},
+ {data: '2001:0DB8:0:0::1', name: 'edge.example.net.', type: 'AAAA', ttl, lookup: '2001:db8::1'}
+ ];
+ expect(dnsQueryView(result, capabilities.resources, 'A', 'example.com', false, t).cards[0].answers).toEqual(expected);
+ expect(dnsLogDetail(data, record.id, 'en-US', t)?.answers).toEqual(expected);
+});
diff --git a/src/features/dns/view.ts b/src/features/dns/view.ts
index 16d3184b3..1f8ed80b6 100644
--- a/src/features/dns/view.ts
+++ b/src/features/dns/view.ts
@@ -5,7 +5,8 @@ import {formatNumber, type Translator as LabelFn} from '../../i18n';
import {csvLine} from '../../ui/ui';
import type {Key} from '../../i18n';
import {dnsTabs} from './nav';
-import {sourceIp} from '../../api/selectors';
+import {sourceIp, ipLiteral} from '../../api/selectors';
+import {isPublicIp} from '../shared/ipLookup';
import {answerAddresses, type QuickRuleSeed} from '../shared/rule';
import {dnsUpstreamNames} from '../../dae/ruleText';
import {unquote} from '../../dae/text';
@@ -35,7 +36,8 @@ const addressSeed = (address: string, name: string, type: string): QuickRuleSeed
const routeSources: Record = {forced: 'dns.route.forced', 'dns.routing': 'dns.route.rules', default: 'dns.route.default'};
type Result = Pick;
-export function dnsAnswerView(result: Result, t: LabelFn) {
+const sameName = (a: string, b: string) => a.replace(/\.$/, '').toLowerCase() === b.replace(/\.$/, '').toLowerCase();
+export function dnsAnswerView(result: Result, name: string, t: LabelFn) {
return {
fields: [
[t('ui.state'), result.status],
@@ -44,7 +46,18 @@ export function dnsAnswerView(result: Result, t: LabelFn) {
[t('dns.routeRule'), result.route.rule ?? '—'],
[t('ui.elapsed'), formatLatency(result.elapsed_ms, t)]
] as Array<[string, string]>,
- answers: (result.answers ?? []).map(answer => t('dns.answer', {name: answer.name, type: answer.type, ttl: answer.ttl, data: answer.data})),
+ answers: (result.answers ?? []).map(answer => {
+ const address = answerAddresses([answer])[0];
+ const ip = address ? ipLiteral(address) : undefined;
+ return {
+ data: answer.data,
+ // The owner shows only when it is not the name asked for, as further down a CNAME chain.
+ name: sameName(answer.name, name) ? null : answer.name,
+ type: answer.type,
+ ttl: t('dns.ttl', {ttl: answer.ttl}),
+ lookup: ip && isPublicIp(ip) ? ip : null
+ };
+ }),
cacheText: t(result.cached ? 'dns.hit' : 'dns.miss'),
cacheTone: result.cached ? undefined : ('warn' as const)
};
@@ -82,7 +95,7 @@ export function dnsQueryView(
result?.results.map(item => ({
id: item.type,
title: `${result.domain} ${item.type}`,
- ...dnsAnswerView(item, t),
+ ...dnsAnswerView(item, result.domain, t),
seed: nameSeed(result.domain, item.type, item.answers ?? [], item.upstream, null),
// Each A or AAAA answer, in the order shown, starts a rule of its own; other records carry no address.
answerSeeds: (item.answers ?? []).map(answer =>
@@ -142,7 +155,7 @@ export function dnsCacheView(
export function dnsLogDetail(data: DnsLogList | undefined, selected: string | null, locale: string, t: LabelFn) {
const record = data?.records.find(item => item.id === selected);
if (!record) return null;
- const answer = dnsAnswerView(record, t);
+ const answer = dnsAnswerView(record, record.question.name, t);
return {
id: record.id,
title: record.question.name,
diff --git a/src/features/shared/ipLookup.test.ts b/src/features/shared/ipLookup.test.ts
new file mode 100644
index 000000000..d53ec0250
--- /dev/null
+++ b/src/features/shared/ipLookup.test.ts
@@ -0,0 +1,88 @@
+import {expect, it} from 'vitest';
+import {ipLookupSites, isPublicIp} from './ipLookup';
+
+it.each([
+ ['0.0.0.0', false],
+ ['0.255.255.255', false],
+ ['1.0.0.0', true],
+ ['9.255.255.255', true],
+ ['10.0.0.0', false],
+ ['10.255.255.255', false],
+ ['11.0.0.0', true],
+ ['100.63.255.255', true],
+ ['100.64.0.0', false],
+ ['100.127.255.255', false],
+ ['100.128.0.0', true],
+ ['126.255.255.255', true],
+ ['127.0.0.0', false],
+ ['127.255.255.255', false],
+ ['128.0.0.0', true],
+ ['169.253.255.255', true],
+ ['169.254.0.0', false],
+ ['169.254.255.255', false],
+ ['169.255.0.0', true],
+ ['172.15.255.255', true],
+ ['172.16.0.0', false],
+ ['172.31.255.255', false],
+ ['172.32.0.0', true],
+ ['192.167.255.255', true],
+ ['192.168.0.0', false],
+ ['192.168.255.255', false],
+ ['192.169.0.0', true],
+ ['223.255.255.255', true],
+ ['224.0.0.0', false],
+ ['239.255.255.255', false],
+ ['240.0.0.0', false],
+ ['255.255.255.255', false],
+ ['::', false],
+ ['::1', false],
+ ['::2', true],
+ ['fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true],
+ ['fc00::', false],
+ ['fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', false],
+ ['fe00::', true],
+ ['fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true],
+ ['fe80::', false],
+ ['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff', false],
+ ['fec0::', true],
+ ['feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true],
+ ['ff00::', false],
+ ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', false],
+ ['192.0.2.1', true],
+ ['198.51.100.1', true],
+ ['203.0.113.1', true],
+ ['2001:db8::1', true],
+ ['::ffff:8.8.8.8', true],
+ ['::ffff:10.0.0.1', false],
+ ['::ffff:192.168.0.1', false],
+ ['::ffff:7f00:1', false],
+ ['::ffff:e000:1', false],
+ ['::ffff:0:0', false],
+ ['', false],
+ ['example.com', false],
+ ['256.1.1.1', false],
+ ['01.2.3.4', false],
+ ['1.2.3', false],
+ ['8.8.8.8:53', false],
+ ['2001:::1', false],
+ ['gggg::1', false],
+ ['fe80::1%eth0', false]
+])('classifies public IP %s as %s', (ip, expected) => {
+ expect(isPublicIp(ip as string)).toBe(expected);
+});
+
+it.each([
+ ['ipinfo.io', 'https://ipinfo.io/'],
+ ['ipapi.is', 'https://ipapi.is/?q='],
+ ['ip-api.com', 'https://ip-api.com/#'],
+ ['bgp.he.net', 'https://bgp.he.net/ip/']
+])('builds normalized IPv4 and IPv6 lookup URLs for %s', (name, prefix) => {
+ for (const [address, ip] of [
+ [' 192.0.2.1 ', '192.0.2.1'],
+ ['[2001:0DB8:0:0::1]', '2001:db8::1']
+ ]) {
+ const sites = ipLookupSites(address);
+ expect(sites.map(site => site.name)).toEqual(['ipinfo.io', 'ipapi.is', 'ip-api.com', 'bgp.he.net']);
+ expect(sites.find(site => site.name === name)?.href).toBe(prefix + ip);
+ }
+});
diff --git a/src/features/shared/ipLookup.ts b/src/features/shared/ipLookup.ts
new file mode 100644
index 000000000..e0ae6a644
--- /dev/null
+++ b/src/features/shared/ipLookup.ts
@@ -0,0 +1,35 @@
+import {ipLiteral} from '../../api/selectors';
+
+export function isPublicIp(ip: string): boolean {
+ let value = ipLiteral(ip);
+ if (!value) return false;
+ const mapped = value.match(/^::ffff:([\da-f]+):([\da-f]+)$/);
+ if (mapped) {
+ const bits = parseInt(mapped[1], 16) * 65536 + parseInt(mapped[2], 16);
+ value = [bits >>> 24, (bits >>> 16) & 255, (bits >>> 8) & 255, bits & 255].join('.');
+ }
+ if (value.includes(':')) {
+ const first = parseInt(value.split(':')[0] || '0', 16);
+ return value !== '::' && value !== '::1' && (first & 0xfe00) !== 0xfc00 && (first & 0xffc0) !== 0xfe80 && (first & 0xff00) !== 0xff00;
+ }
+ const [a, b] = value.split('.').map(Number);
+ return (
+ a !== 0 &&
+ a !== 10 &&
+ a !== 127 &&
+ a < 224 &&
+ !(a === 100 && b >= 64 && b <= 127) &&
+ !(a === 169 && b === 254) &&
+ !(a === 172 && b >= 16 && b <= 31) &&
+ !(a === 192 && b === 168)
+ );
+}
+export function ipLookupSites(address: string) {
+ const ip = ipLiteral(address);
+ return [
+ {name: 'ipinfo.io', href: `https://ipinfo.io/${ip}`},
+ {name: 'ipapi.is', href: `https://ipapi.is/?q=${ip}`},
+ {name: 'ip-api.com', href: `https://ip-api.com/#${ip}`},
+ {name: 'bgp.he.net', href: `https://bgp.he.net/ip/${ip}`}
+ ];
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index c78fbc155..60f0d29bb 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -490,7 +490,6 @@
"dns.addRoutingRuleFor": "Add routing rule for {domain}",
"dns.addRuleFor": "Add a rule for {value}",
"dns.allTypes": "All supported types",
- "dns.answer": "{name} {type}; TTL {ttl} s; {data}",
"dns.bypassCache": "Bypass cache",
"dns.cacheBusy": "The cache listing is temporarily unavailable",
"dns.cacheUnavailable": "This backend does not provide a cache listing",
@@ -621,6 +620,7 @@
"dns.routeSource": "Route source",
"dns.staleUntil": "Stale until",
"dns.tab.stats": "Statistics",
+ "dns.ttl": "TTL {ttl} s",
"dns.typeUnsupported": "This backend cannot query this record type",
"dns.upstreamAuto": "Automatic",
"dns.viewCache": "View cache",
@@ -850,6 +850,8 @@
"hint.rules": "Routing rules, DNS rules and trace simulation",
"hub.routing": "Routing",
"hub.traffic": "Monitor",
+ "ip.lookupNote": "Opens the site you pick in a new tab and sends it this address.",
+ "ip.lookupTitle": "Look up {ip}",
"lifecycle.degraded": "Degraded",
"lifecycle.draining": "Draining",
"lifecycle.failed": "Failed",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 82329d424..22686495c 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -433,7 +433,6 @@
"dns.addRoutingRuleFor": "为 {domain} 新增路由规则",
"dns.addRuleFor": "为 {value} 新建规则",
"dns.allTypes": "所有支持类型",
- "dns.answer": "{name} {type};TTL {ttl} 秒;{data}",
"dns.bypassCache": "略过缓存",
"dns.cacheBusy": "缓存列表暂时不可用",
"dns.cacheUnavailable": "此后端不提供缓存列表",
@@ -534,6 +533,7 @@
"dns.routeSource": "路由来源",
"dns.staleUntil": "逾期可用至",
"dns.tab.stats": "统计",
+ "dns.ttl": "TTL {ttl} 秒",
"dns.typeUnsupported": "此后端不支持查询此记录类型",
"dns.upstreamAuto": "自动",
"dns.viewCache": "查看缓存",
@@ -733,6 +733,8 @@
"hint.rules": "路由规则、DNS 规则与追踪模拟",
"hub.routing": "路由",
"hub.traffic": "观测",
+ "ip.lookupNote": "所选网站会在新标签页打开,此地址会发送给该网站。",
+ "ip.lookupTitle": "查询 {ip}",
"lifecycle.degraded": "降级",
"lifecycle.draining": "排空中",
"lifecycle.failed": "失败",
diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json
index 9a755c7ad..9f1bc2fe8 100644
--- a/src/i18n/locales/zh-TW.json
+++ b/src/i18n/locales/zh-TW.json
@@ -433,7 +433,6 @@
"dns.addRoutingRuleFor": "為 {domain} 新增路由規則",
"dns.addRuleFor": "為 {value} 新增規則",
"dns.allTypes": "所有支援類型",
- "dns.answer": "{name} {type};TTL {ttl} 秒;{data}",
"dns.bypassCache": "略過快取",
"dns.cacheBusy": "快取清單暫時無法使用",
"dns.cacheUnavailable": "此後端不提供快取清單",
@@ -534,6 +533,7 @@
"dns.routeSource": "路由來源",
"dns.staleUntil": "逾期可用至",
"dns.tab.stats": "統計",
+ "dns.ttl": "TTL {ttl} 秒",
"dns.typeUnsupported": "此後端不支援查詢此記錄類型",
"dns.upstreamAuto": "自動",
"dns.viewCache": "檢視快取",
@@ -733,6 +733,8 @@
"hint.rules": "路由規則、DNS 規則與追蹤模擬",
"hub.routing": "路由",
"hub.traffic": "觀測",
+ "ip.lookupNote": "所選網站會在新分頁開啟,這個位址會送給該網站。",
+ "ip.lookupTitle": "查詢 {ip}",
"lifecycle.degraded": "降級",
"lifecycle.draining": "排空中",
"lifecycle.failed": "失敗",
diff --git a/src/ui/IpLookup.tsx b/src/ui/IpLookup.tsx
new file mode 100644
index 000000000..2723c1337
--- /dev/null
+++ b/src/ui/IpLookup.tsx
@@ -0,0 +1,36 @@
+import {Button, Header, Menu, MenuItem, MenuSection, MenuTrigger, Popover, Text} from 'react-aria-components';
+import {useT} from '../i18n';
+import LinkOut from './icons/LinkOut';
+import './styles/ip-lookup.css';
+
+// A public address that opens a menu of lookup sites; nothing leaves the page until the viewer picks one.
+export function IpLookup({ip, address, sites}: {ip: string; address: string; sites: {name: string; href: string}[]}) {
+ const t = useT();
+ const title = t('ip.lookupTitle', {ip});
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/styles/dns-answer.css b/src/ui/styles/dns-answer.css
new file mode 100644
index 000000000..7b9e52e80
--- /dev/null
+++ b/src/ui/styles/dns-answer.css
@@ -0,0 +1,24 @@
+/* A DNS answer: the data takes the free width and wraps inside it; owner, type and TTL follow in muted text, then
+ any action. Whatever wraps to a line of its own keeps to the row's end. */
+.rp-dns-answer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ column-gap: var(--rp-space-3);
+ row-gap: var(--rp-space-1);
+}
+.rp-dns-answer > .rp-code {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.rp-dns-answer > :not(.rp-code) {
+ margin-inline-start: auto;
+}
+.rp-dns-answer > .meta {
+ display: flex;
+ gap: var(--rp-space-2);
+ color: var(--rp-subtle);
+ font-size: var(--rp-text-xs);
+ white-space: nowrap;
+}
diff --git a/src/ui/styles/ip-lookup.css b/src/ui/styles/ip-lookup.css
new file mode 100644
index 000000000..836c6baf4
--- /dev/null
+++ b/src/ui/styles/ip-lookup.css
@@ -0,0 +1,27 @@
+/* An address that opens its lookup menu stays plain linked text: no fill around the glyphs on hover, on press or while
+ its menu is open (React Aria keeps it pressed then). */
+.rp-link.rp-ip-lookup {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.rp-link.rp-ip-lookup:is([data-hovered], [data-pressed]) {
+ background-color: transparent;
+}
+/* The menu's heading names the address and says where it goes, on the edge of the plain rows under it. */
+.rp-ip-lookup-head {
+ display: grid;
+ row-gap: var(--rp-space-half);
+ max-inline-size: 16rem;
+ padding-inline-start: var(--rp-space-3);
+}
+.rp-ip-lookup-head > .desc {
+ font-weight: 400;
+ color: var(--rp-subtle);
+}
+.rp-ip-lookup-menu .rp-item {
+ text-decoration: none;
+}
+.rp-ip-lookup-menu .rp-item > .rp-link-out {
+ color: var(--rp-subtle);
+}