diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index 16a964c..6b2f39c 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -36,6 +36,7 @@ import { MigrationModule } from './modules/migration/migration.module'; import { ChangelogModule } from './modules/changelog/changelog.module'; import { FilesModule } from './modules/files/files.module'; import { LootModule } from './modules/loot/loot.module'; +import { TeleportModule } from './modules/teleport/teleport.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -107,6 +108,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; ChangelogModule, FilesModule, LootModule, + TeleportModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/modules/teleport/dto/create-teleport-config.dto.ts b/backend-nest/src/modules/teleport/dto/create-teleport-config.dto.ts new file mode 100644 index 0000000..b150b8e --- /dev/null +++ b/backend-nest/src/modules/teleport/dto/create-teleport-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTeleportConfigDto { + @ApiProperty({ example: 'Default Config' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard NTeleportation settings' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/teleport/dto/import-teleport-config.dto.ts b/backend-nest/src/modules/teleport/dto/import-teleport-config.dto.ts new file mode 100644 index 0000000..0501359 --- /dev/null +++ b/backend-nest/src/modules/teleport/dto/import-teleport-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportTeleportConfigDto { + @ApiProperty({ example: 'Server Import' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Imported from live server' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend-nest/src/modules/teleport/dto/update-teleport-config.dto.ts b/backend-nest/src/modules/teleport/dto/update-teleport-config.dto.ts new file mode 100644 index 0000000..9216eee --- /dev/null +++ b/backend-nest/src/modules/teleport/dto/update-teleport-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTeleportConfigDto { + @ApiPropertyOptional({ example: 'Updated Config' }) + @IsString() + @MaxLength(100) + @IsOptional() + config_name?: string; + + @ApiPropertyOptional({ example: 'Updated description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + is_active?: boolean; +} diff --git a/backend-nest/src/modules/teleport/teleport.controller.ts b/backend-nest/src/modules/teleport/teleport.controller.ts new file mode 100644 index 0000000..1932589 --- /dev/null +++ b/backend-nest/src/modules/teleport/teleport.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeleportService } from './teleport.service'; +import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto'; +import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto'; +import { ImportTeleportConfigDto } from './dto/import-teleport-config.dto'; +import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; + +@ApiTags('teleport') +@ApiBearerAuth() +@Controller('teleport') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class TeleportController { + constructor(private readonly teleportService: TeleportService) {} + + @Get('configs') + @RequirePermission('teleport.view') + @ApiOperation({ summary: 'List teleport configs (summaries)' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.teleportService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('teleport.view') + @ApiOperation({ summary: 'Get full teleport config with data' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.teleportService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('teleport.manage') + @ApiOperation({ summary: 'Create teleport config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) { + return this.teleportService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('teleport.manage') + @ApiOperation({ summary: 'Update teleport config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateTeleportConfigDto, + ) { + return this.teleportService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('teleport.manage') + @ApiOperation({ summary: 'Delete teleport config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.teleportService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('teleport.manage') + @ApiOperation({ summary: 'Deploy teleport config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.teleportService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('teleport.manage') + @ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) { + return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/teleport/teleport.module.ts b/backend-nest/src/modules/teleport/teleport.module.ts new file mode 100644 index 0000000..276899b --- /dev/null +++ b/backend-nest/src/modules/teleport/teleport.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TeleportController } from './teleport.controller'; +import { TeleportService } from './teleport.service'; +import { TeleportConfig } from '../../entities/teleport-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TeleportConfig])], + controllers: [TeleportController], + providers: [TeleportService, NatsService], + exports: [TeleportService], +}) +export class TeleportModule {} diff --git a/backend-nest/src/modules/teleport/teleport.service.ts b/backend-nest/src/modules/teleport/teleport.service.ts new file mode 100644 index 0000000..e90aa28 --- /dev/null +++ b/backend-nest/src/modules/teleport/teleport.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TeleportConfig } from '../../entities/teleport-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto'; +import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto'; + +@Injectable() +export class TeleportService { + private readonly logger = new Logger(TeleportService.name); + + constructor( + @InjectRepository(TeleportConfig) + private readonly teleportRepo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.teleportRepo.find({ + where: { license_id: licenseId }, + select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'], + order: { created_at: 'DESC' }, + }); + return { configs }; + } + + /** Get full config with JSONB data */ + async getConfig(licenseId: string, configId: string) { + const config = await this.teleportRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Teleport config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateTeleportConfigDto) { + const config = this.teleportRepo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.teleportRepo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) { + const config = await this.teleportRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Teleport config not found'); + + if (dto.config_name !== undefined) config.config_name = dto.config_name; + if (dto.description !== undefined) config.description = dto.description; + if (dto.config_data !== undefined) config.config_data = dto.config_data; + if (dto.is_active !== undefined) config.is_active = dto.is_active; + config.updated_at = new Date(); + + const saved = await this.teleportRepo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('Teleport config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.teleportRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Teleport config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write NTeleportation.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/NTeleportation.json', + content: jsonString, + }, + 30000, + ); + + // Reload NTeleportation plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload NTeleportation', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.teleportRepo.update({ license_id: licenseId }, { is_active: false }); + await this.teleportRepo.update( + { id: configId, license_id: licenseId }, + { is_active: true, updated_at: new Date() }, + ); + + return { + success: true, + message: `Config "${config.config_name}" deployed to server`, + config_name: config.config_name, + }; + } catch (error) { + this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy teleport config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import NTeleportation.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read NTeleportation.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/NTeleportation.json', + }, + 30000, + ); + + if (!response) { + throw new HttpException( + 'No response from agent — it may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + // Parse the response content as JSON + const responseData = response as Record; + let configData: Record; + + if (typeof responseData.content === 'string') { + configData = JSON.parse(responseData.content); + } else if (typeof responseData.content === 'object') { + configData = responseData.content; + } else { + throw new HttpException( + 'Unexpected response format from agent', + HttpStatus.BAD_GATEWAY, + ); + } + + // Create new teleport config row + const config = this.teleportRepo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.teleportRepo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import teleport config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +}