import express, { ErrorRequestHandler, NextFunction, Request, RequestHandler, Response } from 'express'; 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(); const api = express.Router(); app.use(express.json()); app.enable('trust proxy'); 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 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); const setters = req.body.record; const cached = await getOrLoad(domain); const { zone } = cached; if (index == null || isNaN(index) || !zone.records[index]) { throw new Error('Invalid record index.'); } const keys = Object.keys(setters); const record = zone.records[index]; if (!setters || keys.length === 0) { res.json({ success: true, message: 'Nothing was changed.', record }); return; } if (setters.type) { const upperType = setters.type.toUpperCase(); if (upperType === 'SOA' && record.type !== DNSRecordType.SOA) { throw new Error('Cannot change type to Start Of Authority.'); } if (!DNSRecordType[upperType] && upperType !== '*') { throw new Error('Unsupported record type.'); } } keys.forEach((key) => { if (record[key]) { record[key] = setters[key]; } }); 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); const cached = await getOrLoad(domain); const { zone } = cached; if (!index || isNaN(index) || !zone.records[index]) { throw new Error('Invalid record index.'); } const record = zone.records[index]; if (record.type === DNSRecordType.SOA) { throw new Error('Cannot delete the Start Of Authority record.'); } zone.records.splice(index, 1); await cache.update(domain, cached); 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; if (!setter) { throw new Error('New record is missing!'); } const missing = ['name', 'type', 'value'].reduce( (list, entry) => (setter[entry] == null ? [...list, entry] : list) , []); if (missing.length) { throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`); } const { name, type, value } = setter; const upperType = type.toUpperCase(); if (upperType === 'SOA') { throw new Error('Cannot add another Start Of Authority record. Please use POST method to modify the existing record.'); } if (!DNSRecordType[upperType] && upperType !== '*') { throw new Error('Unsupported record type.'); } const cached = await getOrLoad(domain); const { zone } = cached; const newRecord = { name, type: upperType, value }; if (!validator.validateRecord(newRecord)) { 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); 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) { 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 }); } else { res.json({ success: true, message: 'Zone reloaded successfully.' }); } }); /** * 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 }); return; } await cache.update(domain, cached); res.json({ success: true, message: 'Successfully updated zone file.', actions }); }); 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); keys.load().catch((e) => console.error(e.stack)); app.listen(port, () => console.log(`listening on ${port}`));