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