initial commit
This commit is contained in:
commit
1555334021
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir : __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
*.zone
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
16
.vscode/settings.json
vendored
Normal file
16
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"eslint.validate": [
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"html"
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
}
|
||||
}
|
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
||||
</p>
|
||||
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
|
||||
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](LICENSE).
|
5
nest-cli.json
Normal file
5
nest-cli.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
16830
package-lock.json
generated
Normal file
16830
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
package.json
Normal file
78
package.json
Normal file
@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "icydns-nxt",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"@nestjs/swagger": "^6.1.3",
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"mysql2": "^2.3.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"sqlite": "^4.1.2",
|
||||
"sqlite3": "^5.1.2",
|
||||
"typeorm": "^0.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "28.1.4",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "28.1.2",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "28.0.5",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "4.0.0",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
26
src/app.module.ts
Normal file
26
src/app.module.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { join } from 'path';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ObjectsModule } from './modules/objects/objects.module';
|
||||
import { ZoneModule } from './modules/zone/zone.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'sqlite',
|
||||
database: join(__dirname, '..', 'data.db'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: true,
|
||||
}),
|
||||
ObjectsModule,
|
||||
ZoneModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
18
src/config/configuration.ts
Normal file
18
src/config/configuration.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT, 10) || 3000,
|
||||
database: process.env.DATABASE_CONFIG
|
||||
? JSON.parse(process.env.DATABASE_CONFIG)
|
||||
: {
|
||||
type: 'sqlite',
|
||||
database: 'data.db',
|
||||
autoLoadEntities: true,
|
||||
synchronize: true,
|
||||
},
|
||||
cacheTTL: parseInt(process.env.ZONE_CACHE_TTL, 10) || 1600,
|
||||
zoneDir: '.',
|
||||
rndc: {
|
||||
host: process.env.RNDC_SERVER || '127.0.0.1',
|
||||
port: parseInt(process.env.RNDC_PORT, 10) || 953,
|
||||
keyFile: process.env.RNDC_KEYFILE || 'rndc.key',
|
||||
},
|
||||
});
|
9
src/decorators/cached-zone.decorator.ts
Normal file
9
src/decorators/cached-zone.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { CachedZone } from 'src/types/dns.interfaces';
|
||||
|
||||
export const ReqCachedZone = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const response = ctx.switchToHttp().getResponse();
|
||||
return response.locals.cached as CachedZone;
|
||||
},
|
||||
);
|
8
src/decorators/domain.decorator.ts
Normal file
8
src/decorators/domain.decorator.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const ReqDomain = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const response = ctx.switchToHttp().getResponse();
|
||||
return response.locals.domain as string;
|
||||
},
|
||||
);
|
29
src/guards/zone-access.guard.ts
Normal file
29
src/guards/zone-access.guard.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ManagerService } from 'src/modules/objects/manager/manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class ZoneAccessGuard implements CanActivate {
|
||||
constructor(private service: ManagerService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader) return false;
|
||||
|
||||
const [base, token] = authHeader.split(' ');
|
||||
if (!base || base.toLowerCase() !== 'bearer' || !token) return false;
|
||||
|
||||
const access = await this.service.getZoneForKey(token);
|
||||
if (!access) return false;
|
||||
|
||||
const domain = request.params?.domain;
|
||||
if (domain && access.zone !== domain) return false;
|
||||
|
||||
response.locals.zone = access;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
67
src/interceptors/domain.interceptor.ts
Normal file
67
src/interceptors/domain.interceptor.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request, Response } from 'express';
|
||||
import { resolve } from 'path';
|
||||
import { from, Observable, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { DNSCacheService } from 'src/modules/objects/dns/dns-cache.service';
|
||||
import { ManagerService } from 'src/modules/objects/manager/manager.service';
|
||||
import { CachedZone } from 'src/types/dns.interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class DomainInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private dns: DNSCacheService,
|
||||
private manage: ManagerService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async getOrLoad(domain: string): Promise<CachedZone> {
|
||||
if (!this.dns.has(domain)) {
|
||||
const exists = await this.manage.getZone(domain);
|
||||
if (!exists) throw new HttpException('No such zonefile', 404);
|
||||
return this.dns.load(
|
||||
domain,
|
||||
resolve(
|
||||
process.cwd(),
|
||||
this.config.get<string>('zoneDir'),
|
||||
`${domain}.zone`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const get = await this.dns.get(domain);
|
||||
|
||||
if (!get) {
|
||||
throw new HttpException('Misconfigured domain zone file.', 400);
|
||||
}
|
||||
|
||||
return get;
|
||||
}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
|
||||
let base = of(null);
|
||||
const domain = request.params.domain;
|
||||
if (domain) {
|
||||
base = from(
|
||||
(async () => {
|
||||
const domainCache = await this.getOrLoad(domain);
|
||||
response.locals.domain = domain;
|
||||
response.locals.cached = domainCache;
|
||||
return null;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
return base.pipe(switchMap(() => next.handle()));
|
||||
}
|
||||
}
|
21
src/main.ts
Normal file
21
src/main.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Icy Network DNS API')
|
||||
.setDescription('Authorize DNS zone changes using a REST API')
|
||||
.setVersion('1.0')
|
||||
.addTag('zone')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/v1', app, document);
|
||||
|
||||
await app.listen(configService.get<number>('port'));
|
||||
}
|
||||
bootstrap();
|
140
src/modules/objects/dns/dns-cache.service.ts
Normal file
140
src/modules/objects/dns/dns-cache.service.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DNSRecordType } from 'src/types/dns.enum';
|
||||
import { CachedZone, DNSRecord, SOARecord } from 'src/types/dns.interfaces';
|
||||
import { readZoneFile } from 'src/utility/dns/reader';
|
||||
import * as validator from 'src/utility/dns/validator';
|
||||
import { RNDCService } from './rndc.service';
|
||||
|
||||
@Injectable()
|
||||
export class DNSCacheService {
|
||||
private cached: Record<string, CachedZone> = {};
|
||||
private ttl = this.config.get<number>('cacheTTL');
|
||||
|
||||
constructor(private rndc: RNDCService, private config: ConfigService) {}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.cached[name] != null;
|
||||
}
|
||||
|
||||
search(
|
||||
cached: CachedZone,
|
||||
name?: string,
|
||||
type?: DNSRecordType,
|
||||
value?: string,
|
||||
strict = false,
|
||||
): DNSRecord[] {
|
||||
return cached.zone.records
|
||||
.filter((zone) => {
|
||||
if (type && zone.type !== type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name && zone.name !== name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
value &&
|
||||
((!strict && !zone.value.includes(value as string)) ||
|
||||
(strict && zone.value !== value))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((record) => {
|
||||
const inx = cached.zone.records.indexOf(record);
|
||||
return {
|
||||
...record,
|
||||
index: inx,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async get(name: string): Promise<CachedZone | null> {
|
||||
const cached = this.cached[name];
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cached.changed.getTime() < new Date().getTime() - this.ttl * 1000) {
|
||||
return this.load(name, cached.file);
|
||||
}
|
||||
|
||||
return this.cached[name];
|
||||
}
|
||||
|
||||
async set(name: string, zone: CachedZone): Promise<void> {
|
||||
this.cached[name] = zone;
|
||||
}
|
||||
|
||||
async load(name: string, file: string): Promise<CachedZone> {
|
||||
const zoneFile = await readZoneFile(file);
|
||||
const cache = {
|
||||
name,
|
||||
file,
|
||||
zone: zoneFile,
|
||||
added: new Date(),
|
||||
changed: new Date(),
|
||||
};
|
||||
this.cached[name] = cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
async save(name: string): Promise<void> {
|
||||
const zone = await this.get(name);
|
||||
if (!zone) {
|
||||
throw new Error('No such cached zone file!');
|
||||
}
|
||||
|
||||
try {
|
||||
await validator.validateAndSave(name, zone);
|
||||
} catch (e: any) {
|
||||
// Reload previous state
|
||||
await this.load(name, zone.file);
|
||||
throw e as Error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
name: string,
|
||||
newZone?: CachedZone,
|
||||
skipReload = false,
|
||||
): Promise<void> {
|
||||
let zone: CachedZone | null;
|
||||
if (newZone) {
|
||||
zone = newZone;
|
||||
} else {
|
||||
zone = await this.get(name);
|
||||
}
|
||||
|
||||
if (!zone) {
|
||||
throw new Error('No such cached zone file!');
|
||||
}
|
||||
|
||||
// Delete marked-for-deletion records
|
||||
zone.zone.records = zone.zone.records.filter(
|
||||
(record) => record.forDeletion !== true,
|
||||
);
|
||||
|
||||
// Set new serial
|
||||
const soa = zone.zone.records.find(
|
||||
(record) => record.type === DNSRecordType.SOA,
|
||||
) as SOARecord;
|
||||
soa.serial = Math.floor(Date.now() / 1000);
|
||||
zone.changed = new Date();
|
||||
|
||||
this.set(name, zone);
|
||||
await this.save(name);
|
||||
|
||||
if (!skipReload) {
|
||||
try {
|
||||
await this.rndc.reload(name);
|
||||
} catch (e: unknown) {
|
||||
// logger.warn('%s automatic zone reload failed:', name, (e as Error).stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
src/modules/objects/dns/dns.module.ts
Normal file
9
src/modules/objects/dns/dns.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DNSCacheService } from './dns-cache.service';
|
||||
import { RNDCService } from './rndc.service';
|
||||
|
||||
@Module({
|
||||
providers: [DNSCacheService, RNDCService],
|
||||
exports: [DNSCacheService, RNDCService],
|
||||
})
|
||||
export class DNSModule {}
|
31
src/modules/objects/dns/rndc.service.ts
Normal file
31
src/modules/objects/dns/rndc.service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
@Injectable()
|
||||
export class RNDCService {
|
||||
private host = this.config.get<string>('rndc.host');
|
||||
private port = this.config.get<number>('rndc.port');
|
||||
private keyFile = this.config.get<string>('rndc.keyFile');
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
private async rndc(command: string, data: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(
|
||||
`rndc -k ${this.keyFile} -s ${this.host} -p ${this.port} ${command} ${data}`,
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async reload(domain: string): Promise<string> {
|
||||
return this.rndc('reload', domain);
|
||||
}
|
||||
}
|
30
src/modules/objects/manager/access.entity.ts
Normal file
30
src/modules/objects/manager/access.entity.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ZoneEntity } from './zone.entity';
|
||||
|
||||
@Entity()
|
||||
export class AccessEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: false })
|
||||
key: string;
|
||||
|
||||
@ManyToOne(() => ZoneEntity, { onDelete: 'CASCADE' })
|
||||
zone: ZoneEntity;
|
||||
|
||||
@CreateDateColumn()
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updated_at: Date;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public expires_at?: Date;
|
||||
}
|
21
src/modules/objects/manager/icynet.entity.ts
Normal file
21
src/modules/objects/manager/icynet.entity.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
} from 'typeorm';
|
||||
import { ZoneEntity } from './zone.entity';
|
||||
|
||||
@Entity()
|
||||
export class IcynetActorEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
icynetUUID?: string;
|
||||
|
||||
@ManyToMany(() => ZoneEntity)
|
||||
@JoinTable()
|
||||
zones: ZoneEntity[];
|
||||
}
|
15
src/modules/objects/manager/manager.module.ts
Normal file
15
src/modules/objects/manager/manager.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AccessEntity } from './access.entity';
|
||||
import { IcynetActorEntity } from './icynet.entity';
|
||||
import { ManagerService } from './manager.service';
|
||||
import { ZoneEntity } from './zone.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AccessEntity, ZoneEntity, IcynetActorEntity]),
|
||||
],
|
||||
providers: [ManagerService],
|
||||
exports: [ManagerService],
|
||||
})
|
||||
export class ManagerModule {}
|
171
src/modules/objects/manager/manager.service.ts
Normal file
171
src/modules/objects/manager/manager.service.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { getToken } from 'src/utility/token';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AccessEntity } from './access.entity';
|
||||
import { IcynetActorEntity } from './icynet.entity';
|
||||
import { ZoneEntity } from './zone.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ManagerService {
|
||||
constructor(
|
||||
@InjectRepository(AccessEntity)
|
||||
private access: Repository<AccessEntity>,
|
||||
@InjectRepository(ZoneEntity)
|
||||
private zone: Repository<ZoneEntity>,
|
||||
@InjectRepository(IcynetActorEntity)
|
||||
private actors: Repository<IcynetActorEntity>,
|
||||
) {}
|
||||
|
||||
public async getZoneForKey(key: string): Promise<ZoneEntity> {
|
||||
const keyData = await this.getKey(key);
|
||||
if (
|
||||
!keyData ||
|
||||
(keyData.expires_at && keyData.expires_at.getTime() < Date.now())
|
||||
)
|
||||
return null;
|
||||
return keyData.zone;
|
||||
}
|
||||
|
||||
public async getKey(key: string): Promise<AccessEntity> {
|
||||
return this.access.findOne({
|
||||
where: { key },
|
||||
relations: ['zone'],
|
||||
});
|
||||
}
|
||||
|
||||
public async getZone(zone: string): Promise<ZoneEntity> {
|
||||
return this.zone.findOne({
|
||||
where: {
|
||||
zone,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getAllKeys(zone: string): Promise<AccessEntity[]> {
|
||||
const obj = await this.getZone(zone);
|
||||
if (!obj) return [];
|
||||
|
||||
return this.access.find({
|
||||
where: { zone: { id: obj.id } },
|
||||
});
|
||||
}
|
||||
|
||||
public async getZonesByIcynetUUID(uuid: string): Promise<ZoneEntity[]> {
|
||||
const actor = await this.actors.findOne({
|
||||
where: { icynetUUID: uuid },
|
||||
});
|
||||
|
||||
if (!actor) return [];
|
||||
|
||||
return actor.zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new temporary API key for Icy Network user for their domain.
|
||||
* @param icynetUUID Icy Network user UUID
|
||||
* @param domain Zone name
|
||||
* @returns Access key when authorized
|
||||
*/
|
||||
public async createIcynetAccessKey(
|
||||
icynetUUID: string,
|
||||
domain: string,
|
||||
): Promise<AccessEntity> {
|
||||
const zones = await this.getZonesByIcynetUUID(icynetUUID);
|
||||
if (!zones.length) return null;
|
||||
|
||||
const entry = zones.find((zone) => zone.zone === domain);
|
||||
if (!entry) return null;
|
||||
|
||||
const newObject = {
|
||||
key: getToken(),
|
||||
zone: entry,
|
||||
expires_at: Date.now() + 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return this.access.save(newObject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant access of a zone to an Icy Network user
|
||||
* @param icynetUUID Icy Network user UUID
|
||||
* @param domain Zone name
|
||||
*/
|
||||
public async authorizeIcynetUser(
|
||||
icynetUUID: string,
|
||||
domain: string,
|
||||
): Promise<boolean> {
|
||||
const zone = await this.getZone(domain);
|
||||
if (!zone) return false;
|
||||
|
||||
const zones = await this.getZonesByIcynetUUID(icynetUUID);
|
||||
if (zones.length && zones.some((item) => item.zone === zone.zone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actor = await this.actors.findOne({
|
||||
where: { icynetUUID },
|
||||
});
|
||||
|
||||
actor.zones = [...zones, zone];
|
||||
await this.actors.save(actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove access of a zone from an Icy Network user
|
||||
* @param icynetUUID Icy Network user UUID
|
||||
* @param domain Zone name
|
||||
*/
|
||||
public async revokeIcynetUser(
|
||||
icynetUUID: string,
|
||||
domain: string,
|
||||
): Promise<boolean> {
|
||||
const zone = await this.getZone(domain);
|
||||
if (!zone) return false;
|
||||
|
||||
const zones = await this.getZonesByIcynetUUID(icynetUUID);
|
||||
if (zones.length || !zones.some((item) => item.zone === zone.zone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actor = await this.actors.findOne({
|
||||
where: { icynetUUID },
|
||||
});
|
||||
|
||||
actor.zones = actor.zones.filter((item) => item.zone !== zone.zone);
|
||||
await this.actors.save(actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API token for accessing a zone.
|
||||
* @param domain Zone to access
|
||||
* @returns Access object
|
||||
*/
|
||||
public async createToken(domain: string): Promise<AccessEntity> {
|
||||
const zone = await this.getZone(domain);
|
||||
if (!zone) return null;
|
||||
|
||||
const newObject = {
|
||||
key: getToken(),
|
||||
zone,
|
||||
};
|
||||
|
||||
return this.access.save(newObject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new zone to be managed.
|
||||
* @param domain Zone to add
|
||||
* @returns Zone object
|
||||
*/
|
||||
public async addZone(domain: string): Promise<ZoneEntity> {
|
||||
const zone = await this.getZone(domain);
|
||||
if (zone) return zone;
|
||||
|
||||
return this.zone.save({
|
||||
zone: domain,
|
||||
});
|
||||
}
|
||||
}
|
22
src/modules/objects/manager/zone.entity.ts
Normal file
22
src/modules/objects/manager/zone.entity.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class ZoneEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: false })
|
||||
zone: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updated_at: Date;
|
||||
}
|
9
src/modules/objects/objects.module.ts
Normal file
9
src/modules/objects/objects.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DNSModule } from './dns/dns.module';
|
||||
import { ManagerModule } from './manager/manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [DNSModule, ManagerModule],
|
||||
exports: [DNSModule, ManagerModule],
|
||||
})
|
||||
export class ObjectsModule {}
|
664
src/modules/zone/zone.controller.ts
Normal file
664
src/modules/zone/zone.controller.ts
Normal file
@ -0,0 +1,664 @@
|
||||
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'),
|
||||
// );
|
||||
}
|
||||
}
|
10
src/modules/zone/zone.module.ts
Normal file
10
src/modules/zone/zone.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ObjectsModule } from '../objects/objects.module';
|
||||
import { ZoneController } from './zone.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ObjectsModule],
|
||||
controllers: [ZoneController],
|
||||
providers: [],
|
||||
})
|
||||
export class ZoneModule {}
|
52
src/types/dns.enum.ts
Normal file
52
src/types/dns.enum.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export enum DNSRecordType {
|
||||
A = 'A',
|
||||
AAAA = 'AAAA',
|
||||
AFSDB = 'AFSDB',
|
||||
APL = 'APL',
|
||||
CAA = 'CAA',
|
||||
CDNSKEY = 'CDNSKEY',
|
||||
CDS = 'CDS',
|
||||
CERT = 'CERT',
|
||||
CNAME = 'CNAME',
|
||||
CSYNC = 'CSYNC',
|
||||
DHCID = 'DHCID',
|
||||
DLV = 'DLV',
|
||||
DNAME = 'DNAME',
|
||||
DNSKEY = 'DNSKEY',
|
||||
DS = 'DS',
|
||||
EUI48 = 'EUI48',
|
||||
EUI64 = 'EUI64',
|
||||
HINFO = 'HINFO',
|
||||
IPSECKEY = 'IPSECKEY',
|
||||
KEY = 'KEY',
|
||||
KX = 'KX',
|
||||
LOC = 'LOC',
|
||||
MX = 'MX',
|
||||
NAPTR = 'NAPTR',
|
||||
NS = 'NS',
|
||||
NSEC = 'NSEC',
|
||||
NSEC3 = 'NSEC3',
|
||||
NSEC3PARAM = 'NSEC3PARAM',
|
||||
OPENPGPKEY = 'OPENPGPKEY',
|
||||
PTR = 'PTR',
|
||||
RRSIG = 'RRSIG',
|
||||
RP = 'RP',
|
||||
SIG = 'SIG',
|
||||
SMIMEA = 'SMIMEA',
|
||||
SOA = 'SOA',
|
||||
SRV = 'SRV',
|
||||
SSHFP = 'SSHFP',
|
||||
TA = 'TA',
|
||||
TKEY = 'TKEY',
|
||||
TLSA = 'TLSA',
|
||||
TSIG = 'TSIG',
|
||||
TXT = 'TXT',
|
||||
URI = 'URI',
|
||||
ZONEMD = 'ZONEMD',
|
||||
SVCB = 'SVCB',
|
||||
HTTPS = 'HTTPS',
|
||||
AXFR = 'AXFR',
|
||||
IXFR = 'IXFR',
|
||||
OPT = 'OPT',
|
||||
Wildcard = '*',
|
||||
}
|
33
src/types/dns.interfaces.ts
Normal file
33
src/types/dns.interfaces.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { DNSRecordType } from './dns.enum';
|
||||
|
||||
export interface DNSRecord {
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
name: string;
|
||||
type: DNSRecordType;
|
||||
value: string;
|
||||
forDeletion?: boolean;
|
||||
}
|
||||
|
||||
export interface SOARecord extends DNSRecord {
|
||||
nameserver: string;
|
||||
email: string;
|
||||
serial: number;
|
||||
refresh: number;
|
||||
retry: number;
|
||||
expire: number;
|
||||
minimum: number;
|
||||
}
|
||||
|
||||
export interface DNSZone {
|
||||
ttl: number;
|
||||
records: DNSRecord[];
|
||||
includes: string[];
|
||||
}
|
||||
|
||||
export interface CachedZone {
|
||||
name: string;
|
||||
file: string;
|
||||
zone: DNSZone;
|
||||
added: Date;
|
||||
changed: Date;
|
||||
}
|
24
src/types/dto/create-record.dto.ts
Normal file
24
src/types/dto/create-record.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DNSRecordType } from '../dns.enum';
|
||||
|
||||
export class CreateRecordDataDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'When specified, will be inserted at this index.',
|
||||
})
|
||||
index?: number;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ required: true, type: 'string' })
|
||||
type: DNSRecordType;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class CreateRecordDto {
|
||||
@ApiProperty({ required: true })
|
||||
record: CreateRecordDataDto | CreateRecordDataDto[];
|
||||
}
|
6
src/types/dto/delete-record.dto.ts
Normal file
6
src/types/dto/delete-record.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DeleteRecordDto {
|
||||
@ApiProperty({ required: true })
|
||||
index: number | number[];
|
||||
}
|
6
src/types/dto/reload-record.dto.ts
Normal file
6
src/types/dto/reload-record.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReloadRecordDto {
|
||||
@ApiProperty({ required: false })
|
||||
ttl?: number;
|
||||
}
|
30
src/types/dto/update-ip.dto.ts
Normal file
30
src/types/dto/update-ip.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateIPDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'IPv4 address, when unspecified, pulls from request (if available)',
|
||||
})
|
||||
ipv4?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'IPv6 address, when unspecified, pulls from request (if available)',
|
||||
})
|
||||
ipv6?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Specify record to update. Defaults to "@"',
|
||||
})
|
||||
subdomain?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Flag the first request as "dual" if you\'re using two requests to update both v4 and v6',
|
||||
})
|
||||
dualRequest?: boolean;
|
||||
}
|
27
src/types/dto/update-record.dto.ts
Normal file
27
src/types/dto/update-record.dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DNSRecordType } from '../dns.enum';
|
||||
|
||||
export class UpdateRecordDataDto {
|
||||
@ApiProperty({ required: true })
|
||||
index: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ required: false, type: 'string' })
|
||||
type?: DNSRecordType;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
value?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
setIndex?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
forDeletion?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateRecordDto {
|
||||
@ApiProperty({ required: true })
|
||||
record: UpdateRecordDataDto | UpdateRecordDataDto[];
|
||||
}
|
102
src/utility/dns/reader.ts
Normal file
102
src/utility/dns/reader.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { DNSRecordType } from 'src/types/dns.enum';
|
||||
import { DNSRecord, SOARecord, DNSZone } from 'src/types/dns.interfaces';
|
||||
|
||||
function cleanString(str: string): string {
|
||||
return str
|
||||
.replace(/;[^"]+$/, '') // Remove comments from the end
|
||||
.replace(/\s+/g, ' ') // Remove duplicate whitespace
|
||||
.trim(); // Remove whitespace from start and end
|
||||
}
|
||||
|
||||
function parseRecordLine(
|
||||
line: string,
|
||||
index: number,
|
||||
lines: string[],
|
||||
): DNSRecord | SOARecord | null {
|
||||
let actualLine = '';
|
||||
const clean = cleanString(line);
|
||||
if (clean.includes('(')) {
|
||||
actualLine += clean;
|
||||
const trimLines = lines.slice(index + 1);
|
||||
for (const trimLine of trimLines) {
|
||||
actualLine += ' ' + cleanString(trimLine);
|
||||
if (trimLine.includes(')')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
actualLine = cleanString(actualLine.replace(/\(|\)/g, ''));
|
||||
} else {
|
||||
actualLine = clean;
|
||||
}
|
||||
|
||||
const split = actualLine.replace(/"\s"/g, '').split(' ');
|
||||
if (split[0] === 'IN' && split[1] === 'NS') {
|
||||
return {
|
||||
name: '',
|
||||
type: DNSRecordType.NS,
|
||||
value: split.slice(2).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
if (split[2] === 'SOA') {
|
||||
return {
|
||||
name: split[0],
|
||||
type: DNSRecordType.SOA,
|
||||
value: split.slice(3).join(' '),
|
||||
nameserver: split[3],
|
||||
email: split[4],
|
||||
serial: parseInt(split[5], 10),
|
||||
refresh: parseInt(split[6], 10),
|
||||
retry: parseInt(split[7], 10),
|
||||
expire: parseInt(split[8], 10),
|
||||
minimum: parseInt(split[9], 10),
|
||||
};
|
||||
}
|
||||
|
||||
if (!actualLine.includes('IN')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: split[0],
|
||||
type: DNSRecordType[<keyof typeof DNSRecordType>split[2]],
|
||||
value: split.slice(3).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseZoneFile(lines: string[]): DNSZone {
|
||||
let ttl = 0;
|
||||
const includes = [];
|
||||
const records: DNSRecord[] = [];
|
||||
|
||||
for (const index in lines) {
|
||||
const line = lines[index];
|
||||
if (line.startsWith('$TTL')) {
|
||||
ttl = parseInt(line.split(' ')[1], 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('$INCLUDE')) {
|
||||
includes.push(...line.split(' ').slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = parseRecordLine(line, parseInt(index, 10), lines);
|
||||
if (record) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ttl,
|
||||
includes,
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readZoneFile(file: string): Promise<DNSZone> {
|
||||
const lines = await fs.readFile(file, { encoding: 'utf-8' });
|
||||
const splitLines = lines.split('\n');
|
||||
return parseZoneFile(splitLines);
|
||||
}
|
64
src/utility/dns/validator.ts
Normal file
64
src/utility/dns/validator.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { exec } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { CachedZone, DNSRecord } from 'src/types/dns.interfaces';
|
||||
import { writeZoneFile } from './writer';
|
||||
|
||||
// TODO: in-depth validation for record types
|
||||
|
||||
const forbiddenCharacters = ['\n', '\r'];
|
||||
const forbiddenOutsideStr = ['$', ';'];
|
||||
|
||||
export function validateRecord(record: DNSRecord): boolean {
|
||||
for (const char of forbiddenCharacters) {
|
||||
if (record.name.includes(char) || record.value.includes(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const char of forbiddenOutsideStr) {
|
||||
if (record.name.includes(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function validateZonefile(
|
||||
domain: string,
|
||||
file: string,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`named-checkzone ${domain} ${file}`, (error, stdout) => {
|
||||
if (error) {
|
||||
const errorFull = stdout.split('\n')[0].split(':');
|
||||
reject(
|
||||
new Error(
|
||||
`Validation error: ${errorFull[0]}: ${errorFull
|
||||
.slice(2)
|
||||
.join(':')}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateAndSave(
|
||||
name: string,
|
||||
zone: CachedZone,
|
||||
): Promise<void> {
|
||||
const tempfile = join(process.cwd(), `.${name}-${Date.now()}.zone`);
|
||||
await writeZoneFile(zone.zone, tempfile);
|
||||
try {
|
||||
await validateZonefile(name, tempfile);
|
||||
} catch (e) {
|
||||
await fs.unlink(tempfile);
|
||||
throw e;
|
||||
}
|
||||
// TODO: cross-device move
|
||||
await fs.rename(tempfile, zone.file);
|
||||
}
|
133
src/utility/dns/writer.ts
Normal file
133
src/utility/dns/writer.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { DNSRecordType } from 'src/types/dns.enum';
|
||||
import { SOARecord, DNSRecord, DNSZone } from 'src/types/dns.interfaces';
|
||||
|
||||
const maxStrLength = 255;
|
||||
const magicPadding = 3;
|
||||
|
||||
/**
|
||||
* Splits and comments SOA record
|
||||
* @param record
|
||||
* @param padI
|
||||
* @param padJ
|
||||
* @returns new lines
|
||||
*/
|
||||
function createSOAString(
|
||||
record: SOARecord,
|
||||
padI: number,
|
||||
padJ: number,
|
||||
): string[] {
|
||||
const name = record.name.padEnd(padI, ' ');
|
||||
const type = record.type.toString().padEnd(padJ, ' ');
|
||||
const padK = ' '.padStart(padI + magicPadding);
|
||||
const padL =
|
||||
['serial', 'refresh', 'retry', 'expire', 'minimum'].reduce(
|
||||
(previous, current) => {
|
||||
const len = `${record[current]}`.length;
|
||||
return previous > len ? previous : len;
|
||||
},
|
||||
0,
|
||||
) + 1;
|
||||
return [
|
||||
`${name} IN ${type} ${record.nameserver} ${record.email} (`,
|
||||
`${padK} ${record.serial.toString().padEnd(padL, ' ')} ; Serial`,
|
||||
`${padK} ${record.refresh.toString().padEnd(padL, ' ')} ; Refresh`,
|
||||
`${padK} ${record.retry.toString().padEnd(padL, ' ')} ; Retry`,
|
||||
`${padK} ${record.expire.toString().padEnd(padL, ' ')} ; Expire`,
|
||||
`${padK} ${record.minimum.toString().padEnd(padL, ' ')} ; Minimum`,
|
||||
`)`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits very long TXT records into multiple lines.
|
||||
* Mandatory for DKIM keys, for example.
|
||||
* @param record
|
||||
* @param padI
|
||||
* @param padJ
|
||||
* @returns new lines
|
||||
*/
|
||||
function splitTXTString(
|
||||
record: DNSRecord,
|
||||
padI: number,
|
||||
padJ: number,
|
||||
): string[] {
|
||||
const name = record.name.padEnd(padI, ' ');
|
||||
const type = record.type.toString().padEnd(padJ, ' ');
|
||||
const strLen = maxStrLength - padI - magicPadding;
|
||||
const padK = ' '.padStart(padI + magicPadding);
|
||||
const splitStrings = [];
|
||||
|
||||
if (record.value.length < strLen) {
|
||||
return [`${name} IN ${type} ${record.value}`];
|
||||
}
|
||||
|
||||
let temporary = record.value.replace(/"/g, '');
|
||||
while (temporary.length > strLen) {
|
||||
splitStrings.push(temporary.substr(0, strLen));
|
||||
temporary = temporary.substr(strLen);
|
||||
}
|
||||
|
||||
splitStrings.push(temporary);
|
||||
|
||||
return [
|
||||
`${name} IN ${type} (`,
|
||||
...splitStrings.map((str) => `${padK} "${str}"`),
|
||||
`)`,
|
||||
];
|
||||
}
|
||||
|
||||
export function createZoneFile(zone: DNSZone): string[] {
|
||||
const file: string[] = [];
|
||||
file.push(`$TTL ${zone.ttl}`);
|
||||
file.push(`; GENERATED BY ICYDNS`);
|
||||
|
||||
let longestName = 0;
|
||||
let longestType = 0;
|
||||
|
||||
// First pass: for nice alignments
|
||||
zone.records.forEach((record) => {
|
||||
if (record.name.length > longestName) {
|
||||
longestName = record.name.length;
|
||||
}
|
||||
|
||||
if (record.type.toString().length > longestType) {
|
||||
longestType = record.type.toString().length;
|
||||
}
|
||||
});
|
||||
|
||||
zone.records.forEach((record) => {
|
||||
if (record.type === DNSRecordType.SOA) {
|
||||
file.push(
|
||||
...createSOAString(record as SOARecord, longestName, longestType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.type === DNSRecordType.TXT) {
|
||||
file.push(...splitTXTString(record, longestName, longestType));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = record.name.padEnd(longestName, ' ');
|
||||
const type = record.type.toString().padEnd(longestType, ' ');
|
||||
file.push(`${name} IN ${type} ${record.value}`);
|
||||
});
|
||||
|
||||
zone.includes.forEach((include) => {
|
||||
file.push(`$INCLUDE ${include}`);
|
||||
});
|
||||
|
||||
file.push('');
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function writeZoneFile(
|
||||
zone: DNSZone,
|
||||
file: string,
|
||||
): Promise<string[]> {
|
||||
const fullText = createZoneFile(zone);
|
||||
await fs.writeFile(file, fullText.join('\n'));
|
||||
return fullText;
|
||||
}
|
50
src/utility/ip/from-request.ts
Normal file
50
src/utility/ip/from-request.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Request } from 'express';
|
||||
import { validv4, validv6 } from './validators';
|
||||
|
||||
export function fromRequest(req: Request): {
|
||||
v4: string | null;
|
||||
v6: string | null;
|
||||
} {
|
||||
let v4: null | string = null;
|
||||
const qv4 = req.query.ipv4 || req.body.ipv4;
|
||||
|
||||
let v6: null | string = null;
|
||||
const qv6 = req.query.ipv6 || req.body.ipv6;
|
||||
|
||||
// Lets begin our trials
|
||||
// Determine Address from request IP
|
||||
v4 = req.ip;
|
||||
|
||||
if (v4 && !validv4(v4)) {
|
||||
v6 = v4;
|
||||
v4 = null;
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (qv4 && validv4(qv4)) {
|
||||
v4 = qv4;
|
||||
}
|
||||
|
||||
if (qv4 === 'ignore') {
|
||||
v4 = null;
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if (qv6 && validv6(qv6)) {
|
||||
v6 = qv6;
|
||||
}
|
||||
|
||||
if (qv6 === 'ignore') {
|
||||
v6 = null;
|
||||
}
|
||||
|
||||
if (v4 === null && v6 === null) {
|
||||
return { v4, v6 };
|
||||
}
|
||||
|
||||
// Remove subnet mask and prefix
|
||||
if (v6) v6 = v6.replace(/\/(\d+)$/, '');
|
||||
if (v4) v4 = v4.replace(/\/(\d+)$/, '');
|
||||
|
||||
return { v4, v6 };
|
||||
}
|
46
src/utility/ip/validators.ts
Normal file
46
src/utility/ip/validators.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export function validv4(ipaddress: string): boolean {
|
||||
if (
|
||||
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
|
||||
ipaddress,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validv6(value: string): boolean {
|
||||
// See https://blogs.msdn.microsoft.com/oldnewthing/20060522-08/?p=31113 and
|
||||
// https://4sysops.com/archives/ipv6-tutorial-part-4-ipv6-address-syntax/
|
||||
const components = value.split(':');
|
||||
if (components.length < 2 || components.length > 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (components[0] !== '' || components[1] !== '') {
|
||||
// Address does not begin with a zero compression ("::")
|
||||
if (!components[0].match(/^[\da-f]{1,4}/i)) {
|
||||
// Component must contain 1-4 hex characters
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let numberOfZeroCompressions = 0;
|
||||
for (let i = 1; i < components.length; ++i) {
|
||||
if (components[i] === '') {
|
||||
// We're inside a zero compression ("::")
|
||||
++numberOfZeroCompressions;
|
||||
if (numberOfZeroCompressions > 1) {
|
||||
// Zero compression can only occur once in an address
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!components[i].match(/^[\da-f]{1,4}/i)) {
|
||||
// Component must contain 1-4 hex characters
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
5
src/utility/token.ts
Normal file
5
src/utility/token.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function getToken() {
|
||||
return crypto.randomBytes(256 / 8).toString('hex');
|
||||
}
|
24
test/app.e2e-spec.ts
Normal file
24
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user