initial commit

This commit is contained in:
Evert Prants 2022-11-06 13:52:54 +02:00
commit 1555334021
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
47 changed files with 19045 additions and 0 deletions

25
.eslintrc.js Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

16
.vscode/settings.json vendored Normal file
View 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
View 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).

BIN
data.db Normal file

Binary file not shown.

5
nest-cli.json Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

16830
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View 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',
},
});

View 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;
},
);

View 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;
},
);

View 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;
}
}

View 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
View 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();

View 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);
}
}
}
}

View 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 {}

View 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);
}
}

View 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;
}

View 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[];
}

View 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 {}

View 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,
});
}
}

View 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;
}

View 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 {}

View 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'),
// );
}
}

View 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
View 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 = '*',
}

View 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;
}

View 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[];
}

View File

@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class DeleteRecordDto {
@ApiProperty({ required: true })
index: number | number[];
}

View File

@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class ReloadRecordDto {
@ApiProperty({ required: false })
ttl?: number;
}

View 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;
}

View 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
View 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);
}

View 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
View 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;
}

View 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 };
}

View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View 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
}
}