icydns/src/modules/zone/zone.controller.ts

665 lines
16 KiB
TypeScript

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[<keyof typeof 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<string[]>(
(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[<keyof typeof 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'),
// );
}
}