Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add DNS-Map to show the global resolution of DNS requests #101

Merged
merged 20 commits into from
Feb 8, 2024
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
22 changes: 22 additions & 0 deletions app/lookup/[domain]/map/_components/ResultsGlobe.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.wrapper :global(.scene-container),
.wrapper :global(.scene-container > canvas) {
width: 100% !important;
height: auto !important;
}

.wrapper :global(.scene-container > div:not([class])) {
width: 100% !important;
}

.wrapper :global(.scene-container > div:not([class]) .marker-wrapper) {
pointer-events: auto !important;
}

.wrapper :global(.scene-container > div:not([class]):hover .marker-wrapper) {
opacity: 0.6;
}

.wrapper :global(.scene-container > div:not([class]) .marker-wrapper:hover) {
opacity: 1 !important;
z-index: 100 !important;
}
158 changes: 158 additions & 0 deletions app/lookup/[domain]/map/_components/ResultsGlobe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client';

import h from 'hyperscript';
import naturalCompare from 'natural-compare-lite';
import { useTheme } from 'next-themes';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { type FC, useEffect, useRef, useState } from 'react';

import { cn } from '@/lib/utils';

import styles from './ResultsGlobe.module.css';

const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });

export const runtime = 'edge';

type ResultsGlobeProps = {
domain: string;
markers: {
code: string;
name: string;
lat: number;
lng: number;
results: {
A: string[];
AAAA: string[];
CNAME: string[];
};
}[];
};

const ResultsGlobe: FC<ResultsGlobeProps> = ({ domain, markers }) => {
const router = useRouter();
const { resolvedTheme } = useTheme();
const wrapperRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState<number | undefined>(undefined);

// Fix to avoid misalignment of labels after resize
useEffect(() => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});

if (wrapperRef.current) {
observer.observe(wrapperRef.current);
}

return () => observer.disconnect();
}, [wrapperRef]);

return (
<div ref={wrapperRef} className={cn(styles.wrapper, 'w-full')}>
<Globe
// Map from https://github.com/vasturiano/three-globe
globeImageUrl={
resolvedTheme === 'dark'
? '/assets/earth-night.jpg'
: '/assets/earth-day.jpg'
}
backgroundColor="rgba(0,0,0,0)"
width={width}
htmlElementsData={markers}
// @ts-expect-error
htmlElement={(d: ResultsGlobeProps['markers'][number]) => {
return h(
'div.marker-wrapper',

h(
'div.flex.flex-col.gap-2.rounded-lg.bg-background.p-2.shadow-md',

h('h3.text-sm.font-bold', d.name),
d.results.A.length
? h(
'div',
d.results.A.sort(naturalCompare)
.slice(0, d.results.A.length > 5 ? 4 : 5)
.map((value) =>
h('p.text-xs.text-muted-foreground', value)
),
d.results.A.length > 5
? h(
'p.text-xs.text-muted-foreground.italic',
`and ${d.results.A.length - 4} more`
)
: undefined
)
: undefined,

d.results.AAAA.length
? h(
'div',
d.results.AAAA.sort(naturalCompare)
.slice(0, d.results.AAAA.length > 5 ? 4 : 5)
.map((value) =>
h('p.text-xs.text-muted-foreground', value)
),
d.results.AAAA.length > 5
? h(
'p.text-xs.text-muted-foreground.italic',
`and ${d.results.AAAA.length - 4} more`
)
: undefined
)
: undefined,

d.results.CNAME.length
? h(
'div',
d.results.CNAME.sort(naturalCompare)
.slice(0, d.results.CNAME.length > 5 ? 4 : 5)
.map((value) =>
h('p.text-xs.text-muted-foreground', value)
),
d.results.CNAME.length > 5
? h(
'p.text-xs.text-muted-foreground.italic',
`and ${d.results.CNAME.length - 4} more`
)
: undefined
)
: undefined,

d.results.A.length === 0 &&
d.results.AAAA.length === 0 &&
d.results.CNAME.length === 0
? h(
'p.text-xs.text-muted-foreground.italic',
'No records found!'
)
: undefined,

h(
'p.text-xs.text-muted-foreground.italic',
h(
'a.underline.decoration-dotted',
{
href: `/lookup/${domain}/dns?resolver=cloudflare&location=${d.code}`,
onclick: (event: MouseEvent) => {
event.preventDefault();
const el = event.target as HTMLAnchorElement;
router.push(el.href);
},
},
'View full results'
)
)
)
);
}}
/>
</div>
);
};

export default ResultsGlobe;
24 changes: 24 additions & 0 deletions app/lookup/[domain]/map/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { type FC, ReactElement, useEffect } from 'react';

type DnsMapErrorProps = {
error: Error & { digest?: string };
reset: () => void;
};

const DnsMapError: FC<DnsMapErrorProps> = ({ error }): ReactElement => {
useEffect(() => {
console.error(error);
}, [error]);

return (
<div className="mt-12 flex flex-col items-center gap-2">
<h2>Something went wrong!</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Digest: {error.digest}
</p>
</div>
);
};
export default DnsMapError;
24 changes: 24 additions & 0 deletions app/lookup/[domain]/map/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { FC, ReactElement } from 'react';

import { Skeleton } from '@/components/ui/skeleton';

const DnsMapLoading: FC = (): ReactElement => (
<div className="flex flex-col gap-8 lg:flex-row">
<div className="flex h-full basis-2/5">
<div className="my-auto grid grid-cols-2 gap-4">
{Array.from({ length: 18 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
</div>
<div className="order-first basis-3/5 lg:order-last">
<div className="mt-12 flex justify-center">
<Skeleton className="my-36 aspect-square w-3/5 rounded-full" />
</div>
</div>
</div>
);

export default DnsMapLoading;
147 changes: 147 additions & 0 deletions app/lookup/[domain]/map/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { isbot } from 'isbot';
import { CheckCircleIcon, InfoIcon, ShieldAlertIcon } from 'lucide-react';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import Link from 'next/link';
import type { FC } from 'react';

import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';

import ResultsGlobe from '@/app/lookup/[domain]/map/_components/ResultsGlobe';
import { REGIONS } from '@/lib/data';
import InternalDoHResolver from '@/lib/resolvers/InternalDoHResolver';

export const runtime = 'edge';

type MapResultsPageProps = {
params: {
domain: string;
};
};

export const generateMetadata = ({
params: { domain },
}: MapResultsPageProps): Metadata => ({
openGraph: {
url: `/lookup/${domain}/map`,
},
alternates: {
canonical: `/lookup/${domain}/map`,
},
});

const MapResultsPage: FC<MapResultsPageProps> = async ({
params: { domain },
}) => {
const userAgent = headers().get('user-agent');
const shouldBlockRequest = !userAgent || isbot(userAgent);

if (shouldBlockRequest) {
console.log('Bot detected, blocking request, UA:', userAgent);
return (
<Alert className="mx-auto mt-24 max-w-max">
<ShieldAlertIcon className="h-4 w-4" />
<AlertTitle>Bot or crawler detected!</AlertTitle>
<AlertDescription>
To protect our infrastructure, this page is not available for bots or
crawlers.
<br />
But don&apos;t be sad, there&apos;s to crawl here anyway.
</AlertDescription>
</Alert>
);
}

const markers = await Promise.all(
Object.entries(REGIONS).map(async ([code, data]) => {
const resolver = new InternalDoHResolver(code, 'cloudflare');
// TODO Optimize this to only required records
const results = await resolver.resolveAllRecords(domain);

return {
...data,
code,
results: {
A: results.A.map((r) => r.data),
AAAA: results.AAAA.map((r) => r.data),
CNAME: results.CNAME.map((r) => r.data),
},
};
})
);

const regionEntries = markers.map((marker) => (
<Link
href={`/lookup/${domain}/dns?resolver=cloudflare&location=${marker.code}`}
key={marker.code}
>
<Button variant="outline" className="flex h-full w-full flex-col">
<h3 className="mb-2 font-semibold">{marker.name}</h3>
<ul>
{marker.results.A.map((ip, index) => (
<li key={index} className="text-sm font-medium">
{ip}
</li>
))}
</ul>
</Button>
</Link>
));

const hasDifferentRecords = markers.some((marker, index) => {
if (index === 0) return false;

const previous = markers[index - 1].results;
const current = marker.results;

const compareRecords = (recordType: 'A' | 'AAAA' | 'CNAME') => {
return (
previous[recordType].length !== current[recordType].length ||
previous[recordType].some((ip, idx) => ip !== current[recordType][idx])
);
};

return (
compareRecords('A') || compareRecords('AAAA') || compareRecords('CNAME')
);
});

return (
<>
<Alert className="mx-auto my-6 max-w-max">
{hasDifferentRecords ? (
<>
<InfoIcon className="h-4 w-4" />
<AlertTitle>Different records detected!</AlertTitle>
<AlertDescription>
Not all regions have the same records for this domain. This{' '}
<i>could</i> be an indication for the use of GeoDNS.
<br /> Keep in mind however, that some providers rotate their IP
addresses, which can also lead to different results.
</AlertDescription>
</>
) : (
<>
<CheckCircleIcon className="h-4 w-4" />
<AlertTitle>All records are the same!</AlertTitle>
<AlertDescription>
All records are the same for all regions. Therefore propagation
was successful and the domain is not using GeoDNS.
</AlertDescription>
</>
)}
</Alert>
<div className="flex flex-col gap-8 lg:flex-row">
<div className="my-auto h-full basis-2/5">
<div className="grid w-full grid-cols-2 gap-4">{regionEntries}</div>
</div>
<div className="order-first basis-3/5 lg:order-last">
<ResultsGlobe domain={domain} markers={markers} />
</div>
</div>
</>
);
};

export default MapResultsPage;
Loading
Loading