diff --git a/.gitignore b/.gitignore index 989637b..0ba5c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /dist /node_modules *.zone +*.env +*.db # Logs logs diff --git a/data.db b/data.db deleted file mode 100644 index efa4a89..0000000 Binary files a/data.db and /dev/null differ diff --git a/src/app.module.ts b/src/app.module.ts index ad442d2..f53906f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { AppService } from './app.service'; import { ObjectsModule } from './modules/objects/objects.module'; import { ZoneModule } from './modules/zone/zone.module'; import configuration from './config/configuration'; +import { IcynetModule } from './modules/icynet/icynet.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import configuration from './config/configuration'; }), ObjectsModule, ZoneModule, + IcynetModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 4a632e1..e20390e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -11,6 +11,7 @@ export default () => ({ }, cacheTTL: parseInt(process.env.ZONE_CACHE_TTL, 10) || 1600, zoneDir: '.', + icynetKey: process.env.ICYNET_KEY || 'ch4ng3 m3!', rndc: { host: process.env.RNDC_SERVER || '127.0.0.1', port: parseInt(process.env.RNDC_PORT, 10) || 953, diff --git a/src/guards/icynet.guard.ts b/src/guards/icynet.guard.ts new file mode 100644 index 0000000..228c8bb --- /dev/null +++ b/src/guards/icynet.guard.ts @@ -0,0 +1,23 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { timingSafeEqual } from 'crypto'; + +@Injectable() +export class IcynetGuard implements CanActivate { + constructor(private config: ConfigService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) return false; + + const [base, token] = authHeader.split(' '); + if (!base || base.toLowerCase() !== 'bearer' || !token) return false; + + const configured = this.config.get('icynetKey'); + + return timingSafeEqual(Buffer.from(token), Buffer.from(configured)); + } +} diff --git a/src/modules/icynet/icynet.controller.ts b/src/modules/icynet/icynet.controller.ts new file mode 100644 index 0000000..e150e5c --- /dev/null +++ b/src/modules/icynet/icynet.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { IcynetGuard } from 'src/guards/icynet.guard'; +import { ManagerService } from '../objects/manager/manager.service'; +import { ZoneEntity } from '../objects/manager/zone.entity'; + +@ApiExcludeController() +@UseGuards(IcynetGuard) +@Controller({ + path: 'api/v1/icynet', +}) +export class IcynetController { + constructor(private service: ManagerService) {} + + @Get('zones') + async getZoneList(@Query('uuid') uuid?: string) { + let list: ZoneEntity[] = []; + + if (uuid) { + list = await this.service.getZonesByIcynetUUID(uuid); + } else { + list = await this.service.getAllZones(); + } + + return list.map(({ zone }) => zone); + } + + @Get('access') + async getAccessList() { + return this.service.getZonesWithIcynet(); + } + + @Put('zone/:domain/:uuid') + async addZoneAccess( + @Param('uuid') uuid: string, + @Param('domain') domain: string, + ) { + const success = await this.service.authorizeIcynetUser(uuid, domain); + return { success }; + } + + @Delete('zone/:domain/:uuid') + async removeZoneAccess( + @Param('uuid') uuid: string, + @Param('domain') domain: string, + ) { + const success = await this.service.revokeIcynetUser(uuid, domain); + return { success }; + } + + @Post('zone/:domain/:uuid') + async getAccess( + @Param('uuid') uuid: string, + @Param('domain') domain: string, + ) { + const added = await this.service.createIcynetAccessKey(uuid, domain); + if (!added) throw new HttpException('Invalid request', 400); + return { token: added.key }; + } + + @Put('zone/:domain') + async addZone(@Param('domain') domain: string) { + const added = await this.service.addZone(domain); + return { success: !!added }; + } + + @Delete('zone/:domain') + async removeZone(@Param('domain') domain: string) { + const success = await this.service.removeZone(domain); + return { success }; + } +} diff --git a/src/modules/icynet/icynet.module.ts b/src/modules/icynet/icynet.module.ts new file mode 100644 index 0000000..fc9b2f4 --- /dev/null +++ b/src/modules/icynet/icynet.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ObjectsModule } from '../objects/objects.module'; +import { IcynetController } from './icynet.controller'; + +@Module({ + imports: [ObjectsModule], + controllers: [IcynetController], +}) +export class IcynetModule {} diff --git a/src/modules/objects/manager/access.entity.ts b/src/modules/objects/manager/access.entity.ts index b32b9e0..0ce002f 100644 --- a/src/modules/objects/manager/access.entity.ts +++ b/src/modules/objects/manager/access.entity.ts @@ -25,6 +25,6 @@ export class AccessEntity { @UpdateDateColumn() public updated_at: Date; - @Column({ type: 'varchar', nullable: true }) + @Column({ type: 'datetime', nullable: true }) public expires_at?: Date; } diff --git a/src/modules/objects/manager/manager.service.ts b/src/modules/objects/manager/manager.service.ts index 12c9815..1eb15bf 100644 --- a/src/modules/objects/manager/manager.service.ts +++ b/src/modules/objects/manager/manager.service.ts @@ -42,6 +42,10 @@ export class ManagerService { }); } + public async getAllZones(): Promise { + return this.zone.find(); + } + public async getAllKeys(zone: string): Promise { const obj = await this.getZone(zone); if (!obj) return []; @@ -54,6 +58,7 @@ export class ManagerService { public async getZonesByIcynetUUID(uuid: string): Promise { const actor = await this.actors.findOne({ where: { icynetUUID: uuid }, + relations: ['zones'], }); if (!actor) return []; @@ -61,6 +66,20 @@ export class ManagerService { return actor.zones; } + public async getZonesWithIcynet(): Promise> { + const actors = await this.actors.find({ + relations: ['zones'], + }); + + return actors.reduce>((obj, current) => { + for (const item of current.zones) { + if (!obj[item.zone]) obj[item.zone] = []; + obj[item.zone].push(current.icynetUUID); + } + return obj; + }, {}); + } + /** * Create a new temporary API key for Icy Network user for their domain. * @param icynetUUID Icy Network user UUID @@ -80,7 +99,7 @@ export class ManagerService { const newObject = { key: getToken(), zone: entry, - expires_at: Date.now() + 24 * 60 * 60 * 1000, + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000), }; return this.access.save(newObject); @@ -103,10 +122,14 @@ export class ManagerService { return false; } - const actor = await this.actors.findOne({ + let actor = await this.actors.findOne({ where: { icynetUUID }, }); + if (!actor) { + actor = await this.actors.save({ icynetUUID }); + } + actor.zones = [...zones, zone]; await this.actors.save(actor); return true; @@ -125,12 +148,13 @@ export class ManagerService { if (!zone) return false; const zones = await this.getZonesByIcynetUUID(icynetUUID); - if (zones.length || !zones.some((item) => item.zone === zone.zone)) { + if (!zones.length || !zones.some((item) => item.zone === zone.zone)) { return false; } const actor = await this.actors.findOne({ where: { icynetUUID }, + relations: ['zones'], }); actor.zones = actor.zones.filter((item) => item.zone !== zone.zone); @@ -168,4 +192,17 @@ export class ManagerService { zone: domain, }); } + + /** + * Removes a managed zone. + * @param domain Zone to remove + * @returns boolean + */ + public async removeZone(domain: string): Promise { + const zone = await this.getZone(domain); + if (!zone) return false; + + await this.zone.remove(zone); + return true; + } } diff --git a/src/utility/token.ts b/src/utility/token.ts index 933ed9c..1da519a 100644 --- a/src/utility/token.ts +++ b/src/utility/token.ts @@ -1,5 +1,5 @@ -import crypto from 'crypto'; +import { randomBytes } from 'crypto'; export function getToken() { - return crypto.randomBytes(256 / 8).toString('hex'); + return randomBytes(256 / 8).toString('hex'); }