use axfr DNS request to get all zonal entries

This commit is contained in:
2025-10-13 20:19:13 +02:00
parent 9c5d8f3bdc
commit 0991d1a013
5 changed files with 567 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import Input from '$lib/components/Input.svelte';
import TextSize from '$lib/icons/text-size.svelte';
import Quill from '$lib/icons/quill.svelte';
import Tag from '$lib/icons/tag.svelte';
import Dropdown from '../Dropdown.svelte';
const { close }: { close: () => void } = $props();
const dnsTypes = ['A', 'AAAA', 'CNAME', 'NS', 'SOA', 'MX', 'TXT', 'PTR'];
</script>
<form method="POST">
<div class="wrapper">
<Input label="Name" icon={TextSize} placeholder="Website name" required />
<Dropdown
placeholder="Record type"
label="Type"
required={true}
icon={Tag}
options={dnsTypes}
/>
<Input label="Content" icon={Quill} placeholder="IPv4 address" required />
</div>
<footer>
<button on:click={close} aria-disabled="false" type="button" tabindex="0"
><span tabindex="-1">Cancel</span></button
>
<button class="affirmative" type="submit" tabindex="-1">
<span tabindex="-1">Add record</span>
</button>
</footer>
</form>
<style lang="scss">
form {
.wrapper {
display: flex;
flex-direction: column;
}
footer {
padding: 0.75rem 1.5rem;
max-width: 100%;
height: 2.5rem;
width: auto;
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
gap: 1rem;
flex-wrap: wrap;
button {
flex: unset;
span {
font-size: 0.8rem;
}
}
}
}
:global(form .wrapper div) {
margin-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,8 @@
export interface Record {
name: string;
ttl: number;
class: string;
type: string;
data: string;
available?: boolean;
}

266
src/lib/utils/dns.ts Normal file
View File

@@ -0,0 +1,266 @@
import dns from 'dns';
import net from 'net';
let timeout = 0;
const axfrReqProloge =
'\x00\x00' + // Size
'\x00\x00' + // Transaction ID
'\x00\x20' + // Flags: Standard Query
'\x00\x01' + // Number of questions
'\x00\x00' + // Number of answers
'\x00\x00' + // Number of Authority RRs
'\x00\x00'; // Number of Additional RRs
const axfrReqEpiloge =
'\x00' + // End of name
'\x00\xfc' + // Type: AXFR
'\x00\x01'; // Class: IN
function inet_ntoa(num: number): string {
const nbuffer = new ArrayBuffer(4);
const ndv = new DataView(nbuffer);
ndv.setUint32(0, num);
const a: number[] = [];
for (let i = 0; i < 4; i++) {
a[i] = ndv.getUint8(i);
}
return a.join('.');
}
interface DecompressedLabel {
len: number;
name: string;
next?: number;
}
function decompressLabel(data: Buffer, offset: number): DecompressedLabel {
const res: DecompressedLabel = { len: 0, name: '' };
let loffset = offset;
let tmpoff = 0;
while (data[loffset] !== 0x00) {
// Check for pointers
if ((data[loffset] & 0xc0) === 0xc0) {
const newoffset = data.readUInt16BE(loffset) & 0x3fff;
const label = decompressLabel(data, newoffset + 2);
res.name += label.name;
loffset += 1;
break;
} else {
// Normal label
tmpoff = loffset + 1;
res.name += data.toString('utf8', tmpoff, tmpoff + data[loffset]);
res.name += '.';
loffset += data[loffset] + 1;
}
}
res.next = loffset + 1;
return res;
}
interface DNSResult {
questions: any[];
answers: any[];
}
function parseResponse(response: Buffer, result: DNSResult): DNSResult | number {
let offset = 14;
let len = response.readUInt16BE(0);
if (response.length !== len + 2) return -1; // Invalid length
if ((response[4] & 0x80) !== 0x80) return -2; // Not a query response
if ((response[5] & 0x0f) !== 0) return -3; // Error code present
const questions = response.readUInt16BE(6);
const answers = response.readUInt16BE(8);
const authRRs = response.readUInt16BE(10);
const aditRRs = response.readUInt16BE(12);
// Parse queries
for (let x = 0; x < questions; x++) {
const entry = decompressLabel(response, offset);
result.questions.push({
name: entry.name,
type: 'AXFR'
});
offset = (entry.next ?? 0) + 4;
}
// Parse answers
for (let x = 0; x < answers; x++) {
let entry: any = {};
entry = decompressLabel(response, offset);
offset = entry.next!;
entry.name = entry.name;
const type = response.readUInt16BE(offset);
const rclass = response.readUInt16BE(offset + 2);
entry.ttl = response.readUInt32BE(offset + 4);
const rlen = response.readUInt16BE(offset + 8);
// Skip non-INET
if (rclass !== 0x01) {
offset += rlen + 10;
continue;
}
switch (type) {
case 0x01: // A
entry.type = 'A';
entry.a = inet_ntoa(response.readUInt32BE(offset + 10));
break;
case 0x02: // NS
entry.type = 'NS';
entry.ns = decompressLabel(response, offset + 10).name;
break;
case 0x05: // CNAME
entry.type = 'CNAME';
entry.cname = decompressLabel(response, offset + 10).name;
break;
case 0x06: {
// SOA
entry.type = 'SOA';
let tentry = decompressLabel(response, offset + 10);
entry.dns = tentry.name;
tentry = decompressLabel(response, tentry.next!);
entry.mail = tentry.name;
entry.serial = response.readUInt32BE(tentry.next!);
entry.refreshInterval = response.readUInt32BE(tentry.next! + 4);
entry.retryInterval = response.readUInt32BE(tentry.next! + 8);
entry.expireLimit = response.readUInt32BE(tentry.next! + 12);
entry.minTTL = response.readUInt32BE(tentry.next! + 16);
break;
}
case 0x0f: // MX
entry.type = 'MX';
entry.pref = response.readUInt16BE(offset + 10);
entry.mx = decompressLabel(response, offset + 12).name;
break;
case 0x10: // TXT
entry.type = 'TXT';
len = response[offset + 10];
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
break;
case 0x1c: {
// AAAA
entry.type = 'AAAA';
// const byteArr = new Uint8Array(response.slice(offset + 10, offset + 26));
// entry.aaaa = fromByteArray(byteArr).toString();
console.warn('unable to parse AAAA DNS records');
entry.aaaa = '::';
break;
}
case 0x63: // SPF
entry.type = 'SPF';
len = response[offset + 10];
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
break;
case 0x21: // SRV
entry.type = 'SRV';
entry.priority = response.readUInt16BE(offset + 10);
entry.weight = response.readUInt16BE(offset + 12);
entry.port = response.readUInt16BE(offset + 14);
entry.target = decompressLabel(response, offset + 16).name;
break;
}
delete entry.len;
delete entry.next;
result.answers.push(entry);
offset += rlen + 10;
}
return result;
}
(dns as any).resolveAxfrTimeout = (milis: number): void => {
timeout = milis;
};
(dns as any).resolveAxfr = (
server: string,
domain: string,
callback: (code: number, result: any) => void
): void => {
const buffers: Buffer[] = [];
const split = domain.split('.');
const results: DNSResult = { questions: [], answers: [] };
let responses: Buffer[] = [];
let len = 0;
let tlen = 0;
const [hostname, portStr] = server.split(':');
const port = Number(portStr) || 53;
// Build request
buffers.push(Buffer.from(axfrReqProloge, 'binary'));
split.forEach((elem) => {
const label = Buffer.from('\x00' + elem, 'utf8');
label.writeUInt8(elem.length, 0);
buffers.push(label);
});
buffers.push(Buffer.from(axfrReqEpiloge, 'binary'));
const buffer = Buffer.concat(buffers);
// Set size and transaction ID
buffer.writeUInt16BE(buffer.length - 2, 0);
buffer.writeUInt16BE(Math.floor(Math.random() * 65535 + 1), 2);
// Connect and send request
const socket = net.connect(port, hostname, () => {
socket.write(buffer.toString('binary'), 'binary');
socket.end();
});
if (timeout) socket.setTimeout(timeout);
socket.on('data', (data: Buffer) => {
if (len === 0) len = data.readUInt16BE(0);
responses.push(data);
tlen += data.length;
if (tlen >= len + 2) {
const buf = Buffer.concat(responses, tlen);
const tmpBuf = buf.slice(0, len + 2);
const res = parseResponse(tmpBuf, results);
if (typeof res !== 'object') {
socket.destroy();
callback(res, 'Error on response');
}
}
if (tlen > len + 2) {
const buf = Buffer.concat(responses, tlen);
const tmpBuf = buf.slice(len + 2);
len = tmpBuf.readUInt16BE(0);
tlen = tmpBuf.length;
responses = [tmpBuf];
}
});
socket.on('timeout', () => {
socket.destroy();
callback(-5, 'Timeout');
});
socket.on('end', () => {
callback(0, results);
});
socket.on('error', () => {
callback(-4, 'Error connecting');
});
};
export default dns;

View File

@@ -0,0 +1,63 @@
import { exec } from 'child_process';
import dns from '$lib/utils/dns';
import type { Record } from '$lib/interfaces/DNS';
import type { PageServerLoad } from './$types';
// Regex for IPv4 validation
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/;
function ping(data: string): Promise<boolean> {
if (!ipv4Regex.test(data)) return;
const ip = data;
console.log(`Starting ping for ip: ${ip}`);
const resolver = '10.0.0.72';
return new Promise((resolve, reject) => {
exec(
`ping -c 1 -t 1.5 -W 1 ${ip} > /dev/null 2>&1 && exit 0 || exit 1`,
(error, stdout, stderr) => {
if (error) {
console.error(`❌ Exec error: ${error.message}`);
reject(error);
}
if (stderr) {
console.error(`⚠️ Stderr: ${stderr}`);
reject(error);
}
console.log('✅ Ping response received:', stdout);
resolve(true);
}
);
});
}
function axfrAsync(server: string, domain: string) {
return new Promise((resolve, reject) => {
dns.resolveAxfr(server, domain, (err, resp) => {
if (err) reject(err);
resolve(resp?.answers);
});
});
}
export const load: PageServerLoad = async () => {
const server = '10.0.0.72';
const domain = 'schleppe';
const records = await axfrAsync(server, domain);
// console.log('records::', records);
const ARecords = records?.filter((r) => r?.type === 'A');
for (let i = 0; i < ARecords.length; i++) {
try {
// const a = await ping(ARecords[i].a);
records[i]['available'] = true;
} catch (_) { }
}
return {
records
};
};

163
src/routes/dns/+page.svelte Normal file
View File

@@ -0,0 +1,163 @@
<script lang="ts">
import Search from '$lib/icons/search.svelte';
import Input from '$lib/components/Input.svelte';
import Table from '$lib/components/Table.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormDNS from '$lib/components/forms/FormDNS.svelte';
import type { Record } from '$lib/interfaces/DNS';
import type { PageData } from '../$types';
let { data }: { data: PageData } = $props();
let recordsFilter = $state('');
let recordsSort = $state('type');
let open = $state(false);
const rawRecords: Record[] = data?.records || [];
let records = $derived(
rawRecords
?.filter(
(r: Record) =>
r.name?.toLowerCase()?.includes(recordsFilter) ||
r.data?.toLowerCase()?.includes(recordsFilter)
)
.sort((a, b) => (a?.[recordsSort] < b?.[recordsSort] ? -1 : 1))
);
</script>
<h1>DNS</h1>
<Table
title="Zonal records"
description="schleppe colors are currently in stock. Overview of currently stocked filament."
columns={['Ping', 'Details', 'TTL']}
data={records}
footer="Last updated on ~some date~"
>
<div slot="actions" class="filament-table-inputs">
<div>
<Input
placeholder={`${records?.[0]?.name || 'record'}`}
icon={Search}
bind:value={recordsFilter}
label="Records filter"
/>
</div>
<button class="affirmative" on:click={() => (open = true)}><span>Add new</span></button>
</div>
<tbody slot="tbody">
{#each records as row (row)}
<tr>
<td>{row?.available === true ? 'up' : 'down'}</td>
<td class="info">
<div class="meta">
<span>name: {row.name}</span>
<span>type: {row.type}</span>
<span>addr: {row.a}</span>
</div>
</td>
<td>{row.ttl}</td>
</tr>
{/each}
</tbody>
</Table>
{#if open}
<Dialog
title="Add DNS record"
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
close={() => (open = false)}
>
<FormDNS close={() => (open = false)} />
</Dialog>
{/if}
<style lang="scss">
.section-element {
.icon {
display: inline-block;
--size: 2rem;
height: var(--size);
width: var(--size);
padding-right: 0.5rem;
&.spin {
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
animation: rotate 6s linear infinite;
transform-origin: calc((var(--size) / 2) - 2px) calc(var(--size) / 2);
}
}
}
.progress {
display: flex;
flex-direction: column;
width: 100%;
span {
margin-top: 0.5rem;
}
}
.filament-table-inputs {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
> div {
max-width: 450px;
}
> button {
flex: unset;
height: 2.6rem;
}
}
/* filament table */
tr td {
&:first-of-type {
}
&.info {
display: table-cell;
vertical-align: middle;
h2 {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.45em;
font-size: 1.1rem;
font-weight: 300;
color: var(--theme);
}
.meta {
display: flex;
gap: 0.3rem;
flex-direction: column;
opacity: 0.6;
word-break: break-all;
}
}
}
.color {
--size: 4rem;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius, 1rem);
}
</style>