import express, { ErrorRequestHandler, NextFunction, Request, RequestHandler, Response } from 'express'; import cors from 'cors'; import 'express-async-errors'; import path from 'path'; import fs from 'fs/promises'; 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'; import { logger } from './log/Logger'; 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(); const api = express.Router(); app.use(express.json()); app.enable('trust proxy'); app.use(cors({ origin: '*', credentials: true })); 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 (!keys.getKey(domain)) { throw new Error('Invalid domain.'); } return cache.load(domain, path.resolve(dir, `${domain}.zone`)); } const get = await cache.get(domain); if (!get) { throw new Error('Misconfigured domain zone file.'); } return get; } api.use((req, res, next) => { const authHeader = req.get('authorization'); if (!authHeader) { res.status(400).json({ success: false, message: 'Missing Authorization header' }); return; } const parts = authHeader.split(' '); if (parts[0].toLowerCase() !== 'bearer') { res.status(400).json({ success: false, message: 'Invalid Authorization header' }); return; } if (!parts[1] || !keys.getDomain(parts[1])) { res.status(401).json({ success: false, message: 'Unauthorized' }); return; } res.locals.token = parts[1]; next(); }); const domainAuthorization: RequestHandler = (req, res, next) => { if (!req.params.domain || !res.locals.token) { next(new Error('Unexpected bad request')); return; } if (keys.getDomain(res.locals.token) !== req.params.domain) { res.status(401).json({ success: false, message: 'Unauthorized access to domain' }); return; } next(); } /** * Get zone records */ api.get('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; const cached = await getOrLoad(domain); 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) { res.json({ records: cache.search(cached, name, type, value), }); return; } res.json(cached.zone.records); }); /** * Update records by their index in the zone file * record: { * index: number; * name?: string; * type?: DNSRecordType; * value?: string; * setIndex?: number; * forDeletion?: boolean; * }[]; */ api.patch('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; let setters = req.body.record; if (!setters) { res.json({ success: true, message: 'Nothing was changed.' }); return; } if (!Array.isArray(setters)) { setters = [setters]; } const cached = await getOrLoad(domain); const { zone } = cached; const changed = []; const errors = []; for (const setter of setters) { const index = parseInt(setter.index, 10); if (index == null || isNaN(index) || !zone.records[index]) { errors.push({ message: 'Invalid record index.' }); continue; } const keys = Object.keys(setter); const record = { ...zone.records[index] }; if (!setter || keys.length === 0) { errors.push({ message: 'Nothing was changed.', record, }) continue; } if (setter.type) { const upperType = setter.type.toUpperCase(); if (upperType === 'SOA' && record.type !== DNSRecordType.SOA) { errors.push({ message: 'Cannot change type to Start Of Authority.', record }); continue; } if (!DNSRecordType[upperType] && upperType !== '*') { errors.push({ message: 'Unsupported record type.', record }); continue; } } if (record.type === DNSRecordType.SOA && setter.forDeletion) { errors.push({ message: 'Cannot delete the Start Of Authority record.', record }); continue; } keys.forEach((key) => { if (key === 'forDeletion') { record.forDeletion = true; } else if (record[key]) { record[key] = setter[key]; } }); if (!validator.validateRecord(record)) { errors.push({ message: 'Validation error: Invalid characters', record }); continue; } if (setter.setIndex) { const afterI = parseInt(setter.setIndex, 10); if (isNaN(afterI) || afterI > zone.records.length) { errors.push({ message: 'Invalid insertion location!', record }); continue; } zone.records[index] = { ...record, forDeletion: true }; zone.records.splice(afterI, 0, record); changed.push({ ...record, index: afterI }); } else { zone.records[index] = record; changed.push(record); } } await cache.update(domain, cached); if (!changed.length && errors.length) { res.status(400).json({ success: false, message: 'Updating record(s) failed.', changed, errors }); } else if (changed.length) { res.json({ success: true, message: 'Record(s) changed successfully.', changed, errors }); logger.info('zone %s changed records from %s', domain, req.ip); logger.debug(changed); } else { res.json({ success: true, message: 'Nothing was changed.', changed, errors }); } }); /** * 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; let indexes = req.body.index; const cached = await getOrLoad(domain); const { zone } = cached; if (!Array.isArray(indexes)) { indexes = [indexes]; } const deleted = []; const errors = []; for (const number of indexes) { const index = parseInt(number, 10); if (index == null || isNaN(index) || !zone.records[index]) { errors.push({ message: 'Invalid record index.', record: { index } }); continue; } const record = zone.records[index]; if (record.type === DNSRecordType.SOA) { errors.push({ message: 'Cannot delete the Start Of Authority record.', record }); continue; } zone.records[index].forDeletion = true; deleted.push(record); } await cache.update(domain, cached); if (!deleted.length && errors.length) { res.status(400).json({ success: false, message: 'Deleting record(s) failed.', deleted, errors }); } else if (deleted.length) { res.json({ success: true, message: 'Record(s) deleted successfully.', deleted, errors }); logger.info('zone %s deleted records from %s', domain, req.ip); logger.debug(deleted); } else { res.json({ success: true, message: 'Nothing was deleted.', deleted, errors }); } }); /** * Create a new record in zone * record: { * name: string; * type: DNSRecordType; * value: string; * index?: number; * }[]; */ api.post('/zone/records/:domain', domainAuthorization, async (req, res) => { const domain = req.params.domain; let setters = req.body.record; if (!setters) { throw new Error('New record is missing!'); } if (!Array.isArray(setters)) { setters = [setters]; } const cached = await getOrLoad(domain); const { zone } = cached; const created = []; const errors = []; for (const setter of setters) { const missing = ['name', 'type', 'value'].reduce( (list, entry) => (setter[entry] == null ? [...list, entry] : list) , []); if (missing.length) { errors.push({ message: `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`, record: setter }); continue; } const { name, type, value } = setter; const upperType = type.toUpperCase(); if (upperType === 'SOA') { errors.push({ message: 'Cannot add another Start Of Authority record. Please use POST method to modify the existing record.', record: setter }); continue; } if (!DNSRecordType[upperType] && upperType !== '*') { errors.push({ message: 'Unsupported record type.', record: setter }); continue; } const newRecord = { name, type: upperType, value }; if (!validator.validateRecord(newRecord)) { errors.push({ message: 'Validation error: Invalid characters', record: newRecord }); continue; } if (cache.search(cached, name, upperType, value, true).length) { errors.push({ message: 'Exact same record already exists. No need to duplicate records!', record: newRecord }); continue; } if (setter.index) { const afterI = parseInt(setter.index, 10); if (isNaN(afterI) || afterI > zone.records.length) { errors.push({ message: 'Invalid insertion location!', record: newRecord }); continue; } zone.records.splice(afterI, 0, newRecord); created.push({ ...newRecord, index: afterI }); } else { const index = zone.records.push(newRecord) - 1; created.push({ ...newRecord, index }); } } await cache.update(domain, cached); if (!created.length && errors.length) { res.status(400).json({ success: false, message: 'Creating record(s) failed.', created, errors }); } else if (created.length) { res.status(201).json({ success: true, message: 'Record(s) created successfully.', created, errors }); logger.info('zone %s created records from %s', domain, req.ip); logger.debug(created); } else { res.json({ success: true, message: 'Nothing was created.', created, errors }); } }); /** * 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) { 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); if (req.body.ttl) { res.json({ success: true, message: 'TTL changed successfully.', ttl: cached.zone.ttl }); logger.info('zone %s set ttl: %d from %s', domain, cached.zone.ttl, req.ip); } else { res.json({ success: true, message: 'Zone reloaded successfully.' }); logger.info('zone %s reload from %s', domain, req.ip); } }); /** * 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; const { v4, v6 } = fromRequest(req); if (!v4 && !v6) { res.json({ success: true, message: 'Nothing to do.' }); } const cached = await getOrLoad(domain); const { zone } = cached; const actions: string[] = []; if (v4) { const findFirstA = zone.records.find((record) => record.type === DNSRecordType.A && record.name === subdomain ); if (!findFirstA) { zone.records.push({ name: subdomain, type: DNSRecordType.A, value: v4 }); actions.push(`created A record ${v4}`); } else { if (findFirstA.value !== v4) { findFirstA.value = v4; actions.push(`updated A record with ${v4}`); } } } if (v6) { const findFirstAAAA = zone.records.find((record) => record.type === DNSRecordType.AAAA && record.name === subdomain ); if (!findFirstAAAA) { zone.records.push({ name: subdomain, type: DNSRecordType.AAAA, value: v6 }); actions.push(`created AAAA record ${v6}`); } else { if (findFirstAAAA.value !== v6) { findFirstAAAA.value = v6; actions.push(`updated AAAA record with ${v6}`); } } } if (!actions.length) { res.json({ success: true, message: 'Up to date.', actions }); return; } if (waitPartial && ((v4 && !v6) || (!v4 && v6))) { res.status(202).json({ success: true, message: 'Waiting for next request..', actions }); logger.info('set-ip (partial) from %s: %s', req.ip, actions.join('\n')); return; } await cache.update(domain, cached); res.json({ success: true, message: 'Successfully updated zone file.', actions }); logger.info('set-ip from %s: %s', req.ip, actions.join('\n')); }); const errorHandler: ErrorRequestHandler = (err: any, req: Request, res: Response, next: NextFunction) => { res.status(400).json({ success: false, message: err.message }); } api.use(errorHandler); app.use('/api/v1', api); async function load() { await keys.load(); if (logger.logToFile) { try { await fs.stat(logger.logDir); } catch { await fs.mkdir(logger.logDir); } } app.listen(port, () => logger.info(`listening on ${port}`)); } load().catch((e) => console.error(e.stack));