diff --git a/package.json b/package.json index aa67ce6..098210f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { - "name": "icy-dyndns", + "name": "icydns", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc", + "watch": "tsc -w", + "start": "node src/index.js" }, "keywords": [], "author": "", diff --git a/src/dns/cache.ts b/src/dns/cache.ts index 4c7599d..b3824b3 100644 --- a/src/dns/cache.ts +++ b/src/dns/cache.ts @@ -1,4 +1,4 @@ -import { CachedZone, SOARecord } from '../models/interfaces'; +import { CachedZone, DNSRecord, SOARecord } from '../models/interfaces'; import { readZoneFile } from './reader'; import { DNSRecordType } from './records'; import { ReloadExecutor } from './rndc'; @@ -17,6 +17,37 @@ export class DNSCache { return this.cached[name] != null; } + search( + cached: CachedZone, + name?: string, + type?: DNSRecordType, + value?: string, + strict = false + ): DNSRecord[] { + return cached.zone.records.filter((zone) => { + if (type && zone.type !== type) { + return false; + } + + if (name && zone.name !== name) { + return false; + } + + if (value && ((!strict && !zone.value.includes(value as string)) || + (strict && zone.value !== value))) { + return false; + } + + return true; + }).map((record) => { + const inx = cached.zone.records.indexOf(record); + return { + ...record, + index: inx + } + }) + } + async get(name: string): Promise { const cached = this.cached[name]; if (!cached) { diff --git a/src/dns/writer.ts b/src/dns/writer.ts index d25569e..23c4322 100644 --- a/src/dns/writer.ts +++ b/src/dns/writer.ts @@ -1,13 +1,21 @@ import * as fs from 'fs/promises'; -import { DNSZone, SOARecord } from '../models/interfaces'; +import { DNSRecord, DNSZone, SOARecord } from '../models/interfaces'; import { DNSRecordType } from './records'; +const maxStrLength = 255; +const magicPadding = 3; + +/** + * Splits and comments SOA record + * @param record + * @param padI + * @param padJ + * @returns new lines + */ function createSOAString(record: SOARecord, padI: number, padJ: number): string[] { const name = record.name.padEnd(padI, ' '); const type = record.type.toString().padEnd(padJ, ' '); - const padK = ' '.padStart( - padI + 3 - ); + const padK = ' '.padStart(padI + magicPadding); const padL = ['serial', 'refresh', 'retry', 'expire', 'minimum'] .reduce((previous, current) => { const len = `${record[current]}`.length; @@ -24,10 +32,44 @@ function createSOAString(record: SOARecord, padI: number, padJ: number): string[ ]; } -export function createZoneFile(zone: DNSZone, preferredLineLength = 120): string[] { +/** + * Splits very long TXT records into multiple lines. + * Mandatory for DKIM keys, for example. + * @param record + * @param padI + * @param padJ + * @returns new lines + */ +function splitTXTString(record: DNSRecord, padI: number, padJ: number): string[] { + const name = record.name.padEnd(padI, ' '); + const type = record.type.toString().padEnd(padJ, ' '); + const strLen = maxStrLength - padI - magicPadding; + const padK = ' '.padStart(padI + magicPadding); + const splitStrings = []; + + if (record.value.length < strLen) { + return [`${name} IN ${type} ${record.value}`]; + } + + let temporary = record.value.replace(/"/g, ''); + while (temporary.length > strLen) { + splitStrings.push(temporary.substr(0, strLen)); + temporary = temporary.substr(strLen); + } + + splitStrings.push(temporary); + + return [ + `${name} IN ${type} (`, + ...splitStrings.map((str) => `${padK} "${str}"`), + `)` + ] +} + +export function createZoneFile(zone: DNSZone): string[] { const file: string[] = []; file.push(`$TTL ${zone.ttl}`); - file.push(`; GENERATED BY icy-dyndns`); + file.push(`; GENERATED BY ICYDNS`); let longestName = 0; let longestType = 0; @@ -49,6 +91,11 @@ export function createZoneFile(zone: DNSZone, preferredLineLength = 120): string return; } + if (record.type === DNSRecordType.TXT) { + file.push(...splitTXTString(record, longestName, longestType)); + return; + } + const name = record.name.padEnd(longestName, ' '); const type = record.type.toString().padEnd(longestType, ' '); file.push(`${name} IN ${type} ${record.value}`); diff --git a/src/index.ts b/src/index.ts index bb95044..3a015db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ const app = express(); const api = express.Router(); app.use(express.json()); +app.enable('trust proxy'); const keys = new Keys(); const rndc = ReloadExecutor.fromEnvironment(); @@ -84,35 +85,13 @@ api.get('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const cached = await getOrLoad(domain); - const type = req.query.type; - const name = req.query.name; - const value = req.query.value; + const type = req.query.type as DNSRecordType; + const name = req.query.name as string; + const value = req.query.value as string; if (type || name || value) { - const results = cached.zone.records.filter((zone) => { - if (type && zone.type !== type) { - return false; - } - - if (name && zone.name !== name) { - return false; - } - - if (value && !zone.value.includes(value as string)) { - return false; - } - - return true; - }).map((record) => { - const inx = cached.zone.records.indexOf(record); - return { - ...record, - index: inx - } - }); - res.json({ - records: results, + records: cache.search(cached, name, type, value), }); return; @@ -241,6 +220,10 @@ api.put('/zone/records/:domain', domainAuthorization, async (req, res) => { throw new Error('Validation error: Invalid characters'); } + if (cache.search(cached, name, upperType, value, true).length) { + throw new Error('Exact same record already exists. No need to duplicate records!'); + } + zone.records.push(newRecord); await cache.update(domain, cached);