665 lines
16 KiB
TypeScript
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'),
|
|
// );
|
|
}
|
|
}
|