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', /^ { + 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 ( + + + + + +
+ {title} + {t('ip.lookupNote')} +
+ {sites.map(site => ( + + + {site.name} + + + + ))} +
+
+
+
+ ); +} 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); +}