import { Body, Controller, Delete, Get, Header, HttpCode, HttpException, Patch, Post, Put, Query, Req, Res, UseGuards, UseInterceptors, } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags, } from '@nestjs/swagger'; import { ReqCachedZone } from 'src/decorators/cached-zone.decorator'; import { ReqDomain } from 'src/decorators/domain.decorator'; import { ZoneAccessGuard } from 'src/guards/zone-access.guard'; import { DomainInterceptor } from 'src/interceptors/domain.interceptor'; import { DNSRecordType } from 'src/types/dns.enum'; import { CachedZone } from 'src/types/dns.interfaces'; import { UpdateRecordDto } from 'src/types/dto/update-record.dto'; import { DNSCacheService } from '../objects/dns/dns-cache.service'; import * as validator from 'src/utility/dns/validator'; import { DeleteRecordDto } from 'src/types/dto/delete-record.dto'; import { CreateRecordDto } from 'src/types/dto/create-record.dto'; import { createZoneFile } from 'src/utility/dns/writer'; import { ReloadRecordDto } from 'src/types/dto/reload-record.dto'; import { UpdateIPDto } from 'src/types/dto/update-ip.dto'; import { fromRequest } from 'src/utility/ip/from-request'; import { Request, Response } from 'express'; @ApiBearerAuth() @ApiTags('zone') @Controller({ path: '/api/v1/zone' }) @UseGuards(ZoneAccessGuard) @UseInterceptors(DomainInterceptor) export class ZoneController { constructor(public cache: DNSCacheService) {} @Get('types') @ApiOperation({ summary: 'Get available DNS record types', }) public getTypes() { return Object.keys(DNSRecordType); } @Get('records/:domain') @ApiOperation({ summary: 'Get all DNS records for zone, or search by type, name or value', }) @ApiQuery({ name: 'type', type: 'string', required: false, }) @ApiQuery({ name: 'name', type: 'string', required: false, }) @ApiQuery({ name: 'value', type: 'string', required: false, }) @ApiParam({ name: 'domain', description: 'Zone/domain to access', type: 'string', required: true, }) public async getZoneRecords( @ReqCachedZone() cached: CachedZone, @Query() query: { type?: DNSRecordType; name?: string; value: string }, ) { if (query.type || query.name || query.value) { return this.cache.search(cached, query.name, query.type, query.value); } return cached.zone.records; } @Patch('records/:domain') @ApiOperation({ summary: 'Update DNS record(s) by index', }) @ApiBody({ required: true, type: UpdateRecordDto, }) @ApiParam({ name: 'domain', description: 'Zone/domain to update', type: 'string', required: true, }) public async patchRecordByIndex( @Body() body: UpdateRecordDto, @ReqCachedZone() cached: CachedZone, @ReqDomain() domain: string, ) { let setters = body.record; if (!setters) { return { success: true, message: 'Nothing was changed.' }; } if (!Array.isArray(setters)) { setters = [setters]; } const { zone } = cached; const changed = []; const errors = []; for (const setter of setters) { const index = parseInt(String(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(String(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 this.cache.update(domain, cached); if (!changed.length && errors.length) { throw new HttpException( { success: false, message: 'Updating record(s) failed.', changed, errors, }, 400, ); } else if (changed.length) { return { success: true, message: 'Record(s) changed successfully.', changed, errors, }; // logger.info('zone %s changed records from %s', domain, req.ip); // logger.debug(changed); } return { success: true, message: 'Nothing was changed.', changed, errors, }; } @Delete('records/:domain') @ApiOperation({ summary: 'Delete DNS record(s) by index', }) @ApiBody({ required: true, type: DeleteRecordDto, }) @ApiParam({ name: 'domain', description: 'Zone/domain to delete from', type: 'string', required: true, }) public async deleteRecordByIndex( @Body() body: DeleteRecordDto, @ReqCachedZone() cached: CachedZone, @ReqDomain() domain: string, ) { let indexes = body.index; const { zone } = cached as CachedZone; if (!Array.isArray(indexes)) { indexes = [indexes]; } const deleted = []; const errors = []; for (const number of indexes) { const index = parseInt(String(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 this.cache.update(domain, cached); if (!deleted.length && errors.length) { throw new HttpException( { success: false, message: 'Deleting record(s) failed.', deleted, errors, }, 400, ); } else if (deleted.length) { return { success: true, message: 'Record(s) deleted successfully.', deleted, errors, }; // logger.info('zone %s deleted records from %s', domain, req.ip); // logger.debug(deleted); } return { success: true, message: 'Nothing was deleted.', deleted, errors, }; } @Put('records/:domain') @ApiOperation({ summary: 'Create DNS record(s)', }) @ApiBody({ required: true, type: CreateRecordDto, }) @ApiParam({ name: 'domain', description: 'Zone/domain to update with the new record(s)', type: 'string', required: true, }) @HttpCode(201) public async createRecord( @Body() body: CreateRecordDto, @ReqCachedZone() cached: CachedZone, @ReqDomain() domain: string, ) { let setters = body.record; if (!setters) { throw new Error('New record is missing!'); } if (!Array.isArray(setters)) { setters = [setters]; } const { zone } = cached as CachedZone; 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 as DNSRecordType, value }; if (!validator.validateRecord(newRecord)) { errors.push({ message: 'Validation error: Invalid characters', record: newRecord, }); continue; } if ( this.cache.search(cached, name, upperType as DNSRecordType, 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(String(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 this.cache.update(domain, cached); if (!created.length && errors.length) { throw new HttpException( { success: false, message: 'Creating record(s) failed.', created, errors, }, 400, ); } else if (created.length) { return { success: true, message: 'Record(s) created successfully.', created, errors, }; // logger.info('zone %s created records from %s', domain, req.ip); // logger.debug(created); } return { success: true, message: 'Nothing was created.', created, errors, }; } @Get(':domain/download') @ApiOperation({ summary: 'Download zone as file', }) @ApiParam({ name: 'domain', description: 'Zone/domain to download', type: 'string', required: true, }) @Header('Response-Type', 'text/plain') public async downloadZone(@ReqCachedZone() cached: CachedZone) { return createZoneFile(cached.zone).join('\n'); } @Get(':domain') @ApiOperation({ summary: 'Get zone', }) @ApiParam({ name: 'domain', description: 'Zone/domain', type: 'string', required: true, }) public async getZone(@ReqCachedZone() cached: CachedZone) { return cached.zone; } @Post(':domain') @ApiOperation({ summary: 'Reload zone / set TTL', }) @ApiParam({ name: 'domain', description: 'Zone/domain', type: 'string', required: true, }) @ApiBody({ required: false, type: ReloadRecordDto, }) public async reloadZone( @Body() body: ReloadRecordDto, @ReqCachedZone() cached: CachedZone, @ReqDomain() domain: string, ) { if (body.ttl) { const numTTL = parseInt(String(body.ttl), 10); if (isNaN(numTTL)) { throw new Error('Invalid number for TTL'); } cached.zone.ttl = numTTL; } await this.cache.update(domain, cached); if (body.ttl) { return { 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, // ); } return { success: true, message: 'Zone reloaded successfully.' }; // logger.info('zone %s reload from %s', domain, req.ip); } @Post('set-ip/:domain') @ApiOperation({ summary: 'Quickly set IP address for an A/AAAA record, either for root (@) or a subdomain of user choice', }) @ApiParam({ name: 'domain', description: 'Zone/domain', type: 'string', required: true, }) @ApiBody({ required: false, type: UpdateIPDto, }) public async setIP( @Body() body: UpdateIPDto, @Req() req: Request, @Res({ passthrough: true }) res: Response, @ReqCachedZone() cached: CachedZone, @ReqDomain() domain: string, ) { const subdomain = body.subdomain || '@'; const waitPartial = body.dualRequest === true; const { v4, v6 } = fromRequest(req); if (!v4 && !v6) { return { success: true, message: 'Nothing to do. Try providing an IP address.', }; } const { zone } = cached as CachedZone; 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) { return { success: true, message: 'Up to date.', actions, }; } if (waitPartial && ((v4 && !v6) || (!v4 && v6))) { res.status(202); return { success: true, message: 'Waiting for next request..', actions, }; // logger.info( // 'zone %s set-ip (partial) from %s: %s', // domain, // req.ip, // actions.join('\n'), // ); } await this.cache.update(domain, cached); return { success: true, message: 'Successfully updated zone file.', actions, }; // logger.info( // 'zone %s set-ip from %s: %s', // domain, // req.ip, // actions.join('\n'), // ); } }