Skip to content

Commit 37cc92f

Browse files
committed
feat(new tool): DNS/RDAP queries
Co-authored-by: @stevenlee87
1 parent 45ed4a7 commit 37cc92f

3 files changed

Lines changed: 412 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
export const defaultRecordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA'] as const;
2+
3+
export const allRecordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'SRV', 'CAA', 'PTR'] as const;
4+
5+
export type DnsRecordType = typeof allRecordTypes[number];
6+
7+
const dnsTypeNumberToName: Record<number, string> = {
8+
1: 'A',
9+
2: 'NS',
10+
5: 'CNAME',
11+
6: 'SOA',
12+
12: 'PTR',
13+
15: 'MX',
14+
16: 'TXT',
15+
28: 'AAAA',
16+
33: 'SRV',
17+
257: 'CAA',
18+
};
19+
20+
export function getTypeName(typeNumber: number): string {
21+
return dnsTypeNumberToName[typeNumber] ?? `TYPE${typeNumber}`;
22+
}
23+
24+
export interface DnsAnswer {
25+
name: string
26+
type: number
27+
TTL: number
28+
data: string
29+
}
30+
31+
export interface DnsResponse {
32+
Status: number
33+
TC: boolean
34+
RD: boolean
35+
RA: boolean
36+
AD: boolean
37+
CD: boolean
38+
Question: { name: string; type: number }[]
39+
Answer?: DnsAnswer[]
40+
Authority?: DnsAnswer[]
41+
}
42+
43+
export async function queryDns({ domain, type }: { domain: string; type: string }): Promise<DnsResponse> {
44+
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=${encodeURIComponent(type)}`;
45+
46+
const response = await fetch(url, {
47+
headers: { Accept: 'application/dns-json' },
48+
});
49+
50+
if (!response.ok) {
51+
throw new Error(`DNS query failed: ${response.status} ${response.statusText}`);
52+
}
53+
54+
return response.json();
55+
}
56+
57+
export async function queryAllDns(domain: string, types: readonly string[]): Promise<DnsAnswer[]> {
58+
const results = await Promise.allSettled(
59+
types.map(type => queryDns({ domain, type })),
60+
);
61+
62+
const seen = new Set<string>();
63+
const answers: DnsAnswer[] = [];
64+
for (const result of results) {
65+
if (result.status === 'fulfilled' && result.value.Answer) {
66+
for (const answer of result.value.Answer) {
67+
const key = `${answer.type}|${answer.name}|${answer.data}`;
68+
if (!seen.has(key)) {
69+
seen.add(key);
70+
answers.push(answer);
71+
}
72+
}
73+
}
74+
}
75+
76+
return answers;
77+
}
78+
79+
export function formatDnsRecords(answers: DnsAnswer[]): string {
80+
if (answers.length === 0) {
81+
return 'No records found';
82+
}
83+
84+
const lines = answers.map((answer) => {
85+
const typeName = getTypeName(answer.type);
86+
return `${typeName}\t${answer.name}\t${answer.TTL}s\t${answer.data}`;
87+
});
88+
89+
return lines.join('\n');
90+
}
91+
92+
export interface WhoisInfo {
93+
domainName: string
94+
registrar: string
95+
registrationDate: string
96+
expirationDate: string
97+
updatedDate: string
98+
status: string[]
99+
nameServers: string[]
100+
dnssec: string
101+
registrantCountry: string
102+
registrantProvince: string
103+
}
104+
105+
export async function queryWhois(domain: string): Promise<WhoisInfo | null> {
106+
try {
107+
const response = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`);
108+
if (!response.ok) {
109+
return null;
110+
}
111+
const data = await response.json();
112+
return parseRdapResponse(data, domain);
113+
}
114+
catch {
115+
return null;
116+
}
117+
}
118+
119+
function parseRdapResponse(data: any, domain: string): WhoisInfo {
120+
const events = data.events ?? [];
121+
const findEvent = (action: string) => {
122+
const ev = events.find((e: any) => e.eventAction === action);
123+
return ev?.eventDate ? formatDate(ev.eventDate) : '';
124+
};
125+
126+
const registrarEntity = (data.entities ?? []).find((e: any) => e.roles?.includes('registrar'));
127+
const registrar = registrarEntity?.vcardArray?.[1]
128+
?.find((v: any) => v[0] === 'fn')?.[3] ?? '';
129+
130+
const registrantEntity = (data.entities ?? []).find((e: any) => e.roles?.includes('registrant'));
131+
const registrantVcard = registrantEntity?.vcardArray?.[1] ?? [];
132+
const adr = registrantVcard.find((v: any) => v[0] === 'adr');
133+
const registrantCountry = adr?.[1]?.cc ?? '';
134+
const registrantProvince = adr?.[3]?.[4] ?? '';
135+
136+
const nameServers = (data.nameservers ?? [])
137+
.map((ns: any) => (ns.ldhName ?? '').replace(/\.$/, ''))
138+
.filter(Boolean);
139+
140+
const status = (data.status ?? []).map((s: string) => s.replaceAll(' ', ''));
141+
142+
const dnssec = data.secureDNS?.delegationSigned ? 'signed' : 'unsigned';
143+
144+
return {
145+
domainName: data.ldhName ?? domain,
146+
registrar,
147+
registrationDate: findEvent('registration'),
148+
expirationDate: findEvent('expiration'),
149+
updatedDate: findEvent('last changed'),
150+
status,
151+
nameServers,
152+
dnssec,
153+
registrantCountry,
154+
registrantProvince,
155+
};
156+
}
157+
158+
function formatDate(isoDate: string): string {
159+
try {
160+
const d = new Date(isoDate);
161+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
162+
}
163+
catch {
164+
return isoDate;
165+
}
166+
}

src/tools/dns-query/dns-query.vue

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<script setup lang="ts">
2+
import { useCopy } from '@/composable/copy';
3+
import { queryAllDns, queryWhois, formatDnsRecords, getTypeName, defaultRecordTypes } from './dns-query.service';
4+
import type { DnsAnswer, WhoisInfo } from './dns-query.service';
5+
6+
const domain = ref('example.com');
7+
const isLoading = ref(false);
8+
const errorMessage = ref('');
9+
const answers = ref<DnsAnswer[]>([]);
10+
const whoisInfo = ref<WhoisInfo | null>(null);
11+
const whoisLoading = ref(false);
12+
const hasQueried = ref(false);
13+
14+
const domainValidationRules = [
15+
{
16+
message: 'Please enter a valid domain name',
17+
validator: (value: string) => /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(value.trim()),
18+
},
19+
];
20+
21+
const groupedAnswers = computed(() => {
22+
const groups: { type: string; records: DnsAnswer[] }[] = [];
23+
const seen = new Map<string, DnsAnswer[]>();
24+
25+
for (const answer of answers.value) {
26+
const typeName = getTypeName(answer.type);
27+
if (!seen.has(typeName)) {
28+
const records: DnsAnswer[] = [];
29+
seen.set(typeName, records);
30+
groups.push({ type: typeName, records });
31+
}
32+
seen.get(typeName)!.push(answer);
33+
}
34+
35+
return groups;
36+
});
37+
38+
const formattedResult = computed(() => formatDnsRecords(answers.value));
39+
40+
const { copy } = useCopy({ source: formattedResult, text: 'DNS records copied to the clipboard' });
41+
42+
async function doQuery() {
43+
const trimmed = domain.value.trim();
44+
if (!trimmed) {
45+
return;
46+
}
47+
48+
isLoading.value = true;
49+
errorMessage.value = '';
50+
answers.value = [];
51+
whoisInfo.value = null;
52+
hasQueried.value = true;
53+
54+
try {
55+
answers.value = await queryAllDns(trimmed, defaultRecordTypes);
56+
}
57+
catch (err: unknown) {
58+
errorMessage.value = err instanceof Error ? err.message : 'DNS query failed';
59+
}
60+
finally {
61+
isLoading.value = false;
62+
}
63+
64+
whoisLoading.value = true;
65+
try {
66+
whoisInfo.value = await queryWhois(trimmed);
67+
}
68+
catch {
69+
// WHOIS is best-effort
70+
}
71+
finally {
72+
whoisLoading.value = false;
73+
}
74+
}
75+
</script>
76+
77+
<template>
78+
<div>
79+
<c-input-text
80+
v-model:value="domain"
81+
label="Domain name"
82+
placeholder="e.g. example.com"
83+
clearable
84+
autocomplete="off"
85+
autocorrect="off"
86+
autocapitalize="off"
87+
spellcheck="false"
88+
:validation-rules="domainValidationRules"
89+
mb-4
90+
/>
91+
92+
<div flex justify-center mb-4>
93+
<c-button :disabled="!domain.trim() || isLoading" @click="doQuery()">
94+
{{ isLoading ? 'Querying...' : 'Query DNS' }}
95+
</c-button>
96+
</div>
97+
98+
<n-alert v-if="errorMessage" type="error" mb-4>
99+
{{ errorMessage }}
100+
</n-alert>
101+
102+
<div v-if="hasQueried && !isLoading && !errorMessage">
103+
<div v-if="groupedAnswers.length > 0">
104+
<div v-for="group in groupedAnswers" :key="group.type" mb-4>
105+
<div mb-2 font-bold text-15px>
106+
{{ group.type }}
107+
</div>
108+
<n-table :bordered="true" :single-line="false" size="small">
109+
<thead>
110+
<tr>
111+
<th scope="col">Name</th>
112+
<th scope="col">TTL</th>
113+
<th scope="col">Data</th>
114+
</tr>
115+
</thead>
116+
<tbody>
117+
<tr v-for="(answer, index) in group.records" :key="index">
118+
<td>{{ answer.name }}</td>
119+
<td>{{ answer.TTL }}s</td>
120+
<td style="word-break: break-all;">
121+
{{ answer.data }}
122+
</td>
123+
</tr>
124+
</tbody>
125+
</n-table>
126+
</div>
127+
128+
<div flex justify-center mb-5>
129+
<c-button @click="copy()">
130+
Copy results
131+
</c-button>
132+
</div>
133+
</div>
134+
135+
<c-card v-else mb-4>
136+
<div italic op-60>
137+
No records found for this domain.
138+
</div>
139+
</c-card>
140+
</div>
141+
142+
<div v-if="whoisLoading" mt-2 op-60 italic>
143+
Loading WHOIS info...
144+
</div>
145+
146+
<div v-if="whoisInfo" mt-2>
147+
<n-divider />
148+
<div mb-3 font-bold text-16px>
149+
WHOIS
150+
</div>
151+
152+
<n-table :bordered="true" :single-line="false" size="small">
153+
<tbody>
154+
<tr>
155+
<td font-500 w-180px>
156+
Domain Name
157+
</td>
158+
<td>{{ whoisInfo.domainName }}</td>
159+
</tr>
160+
<tr v-if="whoisInfo.registrar">
161+
<td font-500>
162+
Registrar
163+
</td>
164+
<td>{{ whoisInfo.registrar }}</td>
165+
</tr>
166+
<tr v-if="whoisInfo.registrationDate">
167+
<td font-500>
168+
Registration Date
169+
</td>
170+
<td>{{ whoisInfo.registrationDate }}</td>
171+
</tr>
172+
<tr v-if="whoisInfo.expirationDate">
173+
<td font-500>
174+
Expiration Date
175+
</td>
176+
<td>{{ whoisInfo.expirationDate }}</td>
177+
</tr>
178+
<tr v-if="whoisInfo.updatedDate">
179+
<td font-500>
180+
Updated Date
181+
</td>
182+
<td>{{ whoisInfo.updatedDate }}</td>
183+
</tr>
184+
<tr v-if="whoisInfo.registrantCountry">
185+
<td font-500>
186+
Registrant
187+
</td>
188+
<td>{{ [whoisInfo.registrantProvince, whoisInfo.registrantCountry].filter(Boolean).join(', ') }}</td>
189+
</tr>
190+
<tr v-if="whoisInfo.nameServers.length > 0">
191+
<td font-500>
192+
Name Servers
193+
</td>
194+
<td>{{ whoisInfo.nameServers.join(', ') }}</td>
195+
</tr>
196+
<tr v-if="whoisInfo.status.length > 0">
197+
<td font-500>
198+
Domain Status
199+
</td>
200+
<td>
201+
<div v-for="s in whoisInfo.status" :key="s">
202+
{{ s }}
203+
</div>
204+
</td>
205+
</tr>
206+
<tr v-if="whoisInfo.dnssec">
207+
<td font-500>
208+
DNSSEC
209+
</td>
210+
<td>{{ whoisInfo.dnssec }}</td>
211+
</tr>
212+
</tbody>
213+
</n-table>
214+
</div>
215+
</div>
216+
</template>

0 commit comments

Comments
 (0)