diff --git a/.gitignore b/.gitignore index b93ff10..cbd3a20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules/ +/keys.json *.zone diff --git a/src/dns/cache.ts b/src/dns/cache.ts index f73e6dd..4c7599d 100644 --- a/src/dns/cache.ts +++ b/src/dns/cache.ts @@ -1,12 +1,17 @@ -import { CachedZone, DNSRecord, DNSZone, SOARecord } from "../models/interfaces"; -import { readZoneFile } from "./reader"; -import { DNSRecordType } from "./records"; -import { writeZoneFile } from "./writer"; +import { CachedZone, SOARecord } from '../models/interfaces'; +import { readZoneFile } from './reader'; +import { DNSRecordType } from './records'; +import { ReloadExecutor } from './rndc'; +import { ValidatorExecutor } from './validator'; export class DNSCache { private cached: Record = {}; - constructor(private ttl = 1600) {} + constructor( + private rndc: ReloadExecutor, + private validator: ValidatorExecutor, + private ttl = 1600 + ) {} has(name: string): boolean { return this.cached[name] != null; @@ -47,10 +52,11 @@ export class DNSCache { if (!zone) { throw new Error('No such cached zone file!'); } - await writeZoneFile(zone.zone, zone.file); + + await this.validator.validateAndSave(name, zone); } - async update(name: string, newZone?: CachedZone): Promise { + async update(name: string, newZone?: CachedZone, skipReload = false): Promise { let zone: CachedZone | null; if (newZone) { zone = newZone; @@ -67,6 +73,14 @@ export class DNSCache { soa.serial = Math.floor(Date.now() / 1000); this.set(name, zone); - return this.save(name); + await this.save(name); + + if (!skipReload) { + try { + await this.rndc.reload(name); + } catch (e) { + console.warn('%s automatic zone reload failed:', name, e.stack); + } + } } } diff --git a/src/dns/rndc.ts b/src/dns/rndc.ts new file mode 100644 index 0000000..ee4eb12 --- /dev/null +++ b/src/dns/rndc.ts @@ -0,0 +1,35 @@ +import { exec } from 'child_process'; + +export class ReloadExecutor { + constructor( + private host = '127.0.0.1', + private port = 953, + private keyFile = 'rndc.key' + ) {} + + static fromEnvironment(): ReloadExecutor { + return new ReloadExecutor( + process.env.RNDC_SERVER, + parseInt(process.env.RNDC_PORT || '953', 10), + process.env.RNDC_KEYFILE + ) + } + + private async rndc(command: string, data: string): Promise { + return new Promise((resolve, reject) => { + exec(`rndc -k ${this.keyFile} -s ${this.host} -p ${this.port} ${command} ${data}`, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + } + ); + }); + } + + public async reload(domain: string): Promise { + return this.rndc('reload', domain); + } +} diff --git a/src/dns/validator.ts b/src/dns/validator.ts new file mode 100644 index 0000000..397267e --- /dev/null +++ b/src/dns/validator.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs/promises'; +import { CachedZone, DNSRecord } from '../models/interfaces'; +import { exec } from 'child_process'; +import { writeZoneFile } from './writer'; +import path from 'path'; + +// TODO: in-depth validation for record types + +const forbiddenCharacters = ['\n', '\r']; +const forbiddenOutsideStr = ['$', ';']; + +export class ValidatorExecutor { + constructor() {} + + public validateRecord(record: DNSRecord): boolean { + for (const char of forbiddenCharacters) { + if (record.name.includes(char) || record.value.includes(char)) { + return false; + } + } + + for (const char of forbiddenOutsideStr) { + if (record.name.includes(char)) { + return false; + } + } + + return true; + } + + public async validateZonefile(domain: string, file: string): Promise { + return new Promise((resolve, reject) => { + exec(`named-checkzone ${domain} ${file}`, + (error, stdout) => { + if (error) { + const errorFull = stdout.split('\n')[0].split(':'); + reject(new Error(`Validation error: ${errorFull[0]}: ${errorFull.slice(2).join(':')}`)); + return; + } + resolve(stdout); + } + ); + }); + } + + public async validateAndSave(name: string, zone: CachedZone): Promise { + const tempfile = path.join(process.cwd(), `.${name}-${Date.now()}.zone`); + await writeZoneFile(zone.zone, tempfile); + try { + await this.validateZonefile(name, tempfile); + } catch (e) { + await fs.unlink(tempfile); + throw e; + } + // TODO: cross-device move + await fs.rename(tempfile, zone.file); + } +} diff --git a/src/index.ts b/src/index.ts index 1598b70..bb95044 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,15 @@ import 'express-async-errors'; import path from 'path'; import { DNSCache } from './dns/cache'; import { DNSRecordType } from './dns/records'; +import { ReloadExecutor } from './dns/rndc'; +import { ValidatorExecutor } from './dns/validator'; import { createZoneFile } from './dns/writer'; import { fromRequest } from './ip/from-request'; +import { Keys } from './keys'; import { CachedZone } from './models/interfaces'; const port = parseInt(process.env.PORT || '9129', 10); +const cacheTTL = parseInt(process.env.CACHE_TTL || '2629746', 10); const dir = process.env.ZONEFILES || '.'; const app = express(); @@ -15,15 +19,14 @@ const api = express.Router(); app.use(express.json()); -const cache = new DNSCache(); -const authentic: {[x: string]: string} = { - 'testing-token-auth-aaaa': 'lunasqu.ee', - 'aaa': 'lol' -}; +const keys = new Keys(); +const rndc = ReloadExecutor.fromEnvironment(); +const validator = new ValidatorExecutor(); +const cache = new DNSCache(rndc, validator, cacheTTL); async function getOrLoad(domain: string): Promise { if (!cache.has(domain)) { - if (!Object.values(authentic).includes(domain)) { + if (!keys.getKey(domain)) { throw new Error('Invalid domain.'); } return cache.load(domain, path.resolve(dir, `${domain}.zone`)); @@ -51,7 +54,7 @@ api.use((req, res, next) => { return; } - if (!parts[1] || !authentic[parts[1]]) { + if (!parts[1] || !keys.getDomain(parts[1])) { res.status(401).json({ success: false, message: 'Unauthorized' }); return; } @@ -66,7 +69,7 @@ const domainAuthorization: RequestHandler = (req, res, next) => { return; } - if (authentic[res.locals.token] !== req.params.domain) { + if (keys.getDomain(res.locals.token) !== req.params.domain) { res.status(401).json({ success: false, message: 'Unauthorized access to domain' }); return; } @@ -74,6 +77,9 @@ const domainAuthorization: RequestHandler = (req, res, next) => { next(); } +/** + * Get zone records + */ api.get('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const cached = await getOrLoad(domain); @@ -115,6 +121,15 @@ api.get('/zone/records/:domain', domainAuthorization, async (req, res) => { res.json(cached.zone.records); }); +/** + * Update a record by its index in the zone file + * index: number; + * record: { + * name?: string; + * type?: DNSRecordType; + * value?: string; + * } + */ api.post('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const index = parseInt(req.body.index, 10); @@ -150,11 +165,19 @@ api.post('/zone/records/:domain', domainAuthorization, async (req, res) => { } }); + if (!validator.validateRecord(record)) { + throw new Error('Validation error: Invalid characters'); + } + await cache.update(domain, cached); res.json({ success: true, message: 'Record changed successfully.', record }); }); +/** + * Delete a record from zone by its index in the zone file + * index: number; + */ api.delete('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const index = parseInt(req.body.index, 10); @@ -176,6 +199,14 @@ api.delete('/zone/records/:domain', domainAuthorization, async (req, res) => { res.json({ success: true, message: 'Record deleted successfully.', record }); }); +/** + * Create a new record in zone + * record: { + * name: string; + * type: DNSRecordType; + * value: string; + * } + */ api.put('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const setter = req.body.record; @@ -206,44 +237,68 @@ api.put('/zone/records/:domain', domainAuthorization, async (req, res) => { const { zone } = cached; const newRecord = { name, type: upperType, value }; + if (!validator.validateRecord(newRecord)) { + throw new Error('Validation error: Invalid characters'); + } + zone.records.push(newRecord); await cache.update(domain, cached); res.status(201).json({ success: true, message: 'Record added.', record: newRecord }); }); +/** + * Get full zone as file + */ api.get('/zone/:domain/download', domainAuthorization, async (req, res) => { const domain = req.params.domain; const cached = await getOrLoad(domain); res.send(createZoneFile(cached.zone).join('\n')); }); +/** + * Get full zone + */ api.get('/zone/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const cached = await getOrLoad(domain); res.json(cached.zone); }); +/** + * Reload zone or change TTL + * ttl?: number + */ api.post('/zone/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const cached = await getOrLoad(domain); - if (!req.body.ttl) { - res.json({ success: true, message: 'Nothing was changed.' }); - return; - } - - const numTTL = parseInt(req.body.TTL, 10); - if (!isNaN(numTTL)) { + if (req.body.ttl) { + const numTTL = parseInt(req.body.TTL, 10); + if (isNaN(numTTL)) { + throw new Error('Invalid number for TTL'); + } cached.zone.ttl = numTTL; } await cache.update(domain, cached); - res.json({ success: true, message: 'TTL changed successfully.', ttl: cached.zone.ttl }); + if (req.body.ttl) { + res.json({ success: true, message: 'TTL changed successfully.', ttl: cached.zone.ttl }); + } else { + res.json({ success: true, message: 'Zone reloaded successfully.' }); + } }); -api.post('/dyndns/:domain', domainAuthorization, async (req, res) => { +/** + * Quickly set IP address for the zone. + * Either for root or a subdomain of user choice. + * ipv4?: string; + * ipv6?: string; + * subdomain?: string; + * dualRequest?: boolean; + */ +api.post('/set-ip/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const subdomain = req.body.subdomain || '@'; const waitPartial = req.body.dualRequest === true; @@ -334,4 +389,5 @@ const errorHandler: ErrorRequestHandler = (err: any, req: Request, res: Response api.use(errorHandler); app.use('/api/v1', api); +keys.load().catch((e) => console.error(e.stack)); app.listen(port, () => console.log(`listening on ${port}`)); diff --git a/src/keys.ts b/src/keys.ts new file mode 100644 index 0000000..72cf561 --- /dev/null +++ b/src/keys.ts @@ -0,0 +1,30 @@ +import * as fs from 'fs/promises'; +import path from 'path'; + +export class Keys { + private keys: Record = {}; + + async load(): Promise { + const file = path.join(__dirname, '..', 'keys.json'); + let content = '{}'; + try { + content = await fs.readFile(file, { encoding: 'utf-8' }); + } catch (e) { + if (e.message.includes('ENOENT')) { + fs.writeFile(file, '{}'); + } else { + throw e; + } + } + + this.keys = JSON.parse(content); + } + + getDomain(key: string): string | undefined { + return this.keys[key]; + } + + getKey(domain: string): string | undefined { + return Object.keys(this.keys).find((key) => this.keys[key] === domain); + } +}