Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/feat-ipinfo-link.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions e2e/connections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
67 changes: 67 additions & 0 deletions e2e/dns.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<title>IP lookup</title>'}));
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();
});
9 changes: 8 additions & 1 deletion src/features/connections/Connections.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {IpLookup} from '../../ui/IpLookup';
import {ipLookupSites} from '../shared/ipLookup';
import {OutboundTag} from '../shared/OutboundTag';
import {
Badge,
Expand Down Expand Up @@ -144,7 +146,12 @@ export function Connections(props: PageProps) {
]}
/>
</div>
<Kv items={cur.fields} />
<Kv
items={cur.fields.map(([label, value]) => [
label,
typeof value === 'string' ? value : <IpLookup ip={value.ip} address={value.address} sites={ipLookupSites(value.ip)} />
])}
/>
<Kv
items={[
[t('ui.outbound'), <OutboundTag {...cur.outboundTag} />],
Expand Down
11 changes: 11 additions & 0 deletions src/features/connections/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
11 changes: 8 additions & 3 deletions src/features/connections/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,14 +19,18 @@ const chainSources: Record<Connection['chain_source'], Key> = {
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)],
Expand Down Expand Up @@ -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
Expand Down
35 changes: 28 additions & 7 deletions src/features/dns/Dns.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof useDnsCacheTab>['rows'][number];
type DnsLogRow = ReturnType<typeof useDnsLogTab>['rows'][number];

type DnsAnswerRow = ReturnType<typeof dnsAnswerView>['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 (
<div className="rp-dns-answer">
<span className="rp-code">
{answer.lookup ? <IpLookup ip={answer.lookup} address={answer.data} sites={ipLookupSites(answer.lookup)} /> : answer.data}
</span>
<span className="meta">
{answer.name && <span>{answer.name}</span>}
<span>{answer.type}</span>
<span>{answer.ttl}</span>
</span>
{children}
</div>
);
}

export function Dns(props: PageProps) {
const t = useT();
const vm = useDns(props);
Expand Down Expand Up @@ -94,14 +118,13 @@ export function Dns(props: PageProps) {
{card.answers.map((answer, i) => {
const address = card.answerSeeds[i];
return (
<div key={i} className="rp-cluster">
<div className="rp-code">{answer}</div>
<DnsAnswer key={i} answer={answer}>
{address && vm.rule.canAdd(address.seed) && (
<Button quiet small label={t('dns.addRuleFor', {value: address.address})} onPress={() => vm.rule.open(address.seed)}>
{t('rule.add')}
</Button>
)}
</div>
</DnsAnswer>
);
})}
</div>
Expand Down Expand Up @@ -362,9 +385,7 @@ function DnsLog({
{vm.detail.answers.length ? (
<div className="rp-list">
{vm.detail.answers.map((answer, i) => (
<div key={i} className="rp-code">
{answer}
</div>
<DnsAnswer key={i} answer={answer} />
))}
</div>
) : (
Expand Down
33 changes: 31 additions & 2 deletions src/features/dns/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
23 changes: 18 additions & 5 deletions src/features/dns/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,7 +36,8 @@ const addressSeed = (address: string, name: string, type: string): QuickRuleSeed

const routeSources: Record<string, Key> = {forced: 'dns.route.forced', 'dns.routing': 'dns.route.rules', default: 'dns.route.default'};
type Result = Pick<DnsQueryResponse['results'][number], 'status' | 'upstream' | 'route' | 'elapsed_ms' | 'answers' | 'cached'>;
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],
Expand All @@ -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)
};
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading