mirror of
https://github.com/KevinMidboe/infra-map.git
synced 2025-10-29 09:30:29 +00:00
use axfr DNS request to get all zonal entries
This commit is contained in:
67
src/lib/components/forms/FormDNS.svelte
Normal file
67
src/lib/components/forms/FormDNS.svelte
Normal 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>
|
||||
8
src/lib/interfaces/DNS.ts
Normal file
8
src/lib/interfaces/DNS.ts
Normal 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
266
src/lib/utils/dns.ts
Normal 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;
|
||||
63
src/routes/dns/+page.server.ts
Normal file
63
src/routes/dns/+page.server.ts
Normal 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
163
src/routes/dns/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user