import { Controller, Get, Post, Put, Delete, Body, Param, Query, Headers, NotFoundException, BadRequestException, UnauthorizedException, } from '@nestjs/common'; import * as crypto from 'crypto'; import { ConfigService } from '@nestjs/config'; import { PoolServerRepository } from '../../../infrastructure/repositories/pool-server.repository'; import { CredentialVaultService } from '../../../infrastructure/crypto/credential-vault.service'; import { PoolServer } from '../../../domain/entities/pool-server.entity'; @Controller('api/v1/inventory/pool-servers') export class PoolServerController { private readonly internalApiKey: string; constructor( private readonly poolServerRepository: PoolServerRepository, private readonly vault: CredentialVaultService, private readonly configService: ConfigService, ) { this.internalApiKey = this.configService.get('INTERNAL_API_KEY', ''); } private serialize(server: PoolServer) { // Never return raw encrypted key const { sshKeyEncrypted, sshKeyIv, ...safe } = server; return { ...safe, hasKey: !!sshKeyEncrypted, available: server.status === 'active' && server.currentInstances < server.maxInstances, freeSlots: Math.max(0, server.maxInstances - server.currentInstances), }; } @Get() async list(@Query('available') available?: string) { const servers = available === 'true' ? await this.poolServerRepository.findAvailable() : await this.poolServerRepository.findAll(); return servers.map((s) => this.serialize(s)); } @Get(':id') async getOne(@Param('id') id: string) { const server = await this.poolServerRepository.findById(id); if (!server) throw new NotFoundException(`Pool server ${id} not found`); return this.serialize(server); } @Post() async create(@Body() body: { name: string; host: string; sshPort?: number; sshUser: string; sshKey: string; // plaintext private key, encrypted immediately maxInstances?: number; region?: string; notes?: string; }) { if (!body.name || !body.host || !body.sshUser || !body.sshKey) { throw new BadRequestException('name, host, sshUser, sshKey are required'); } const { encrypted, iv } = this.vault.encrypt(body.sshKey); const server = new PoolServer(); server.id = crypto.randomUUID(); server.name = body.name; server.host = body.host; server.sshPort = body.sshPort ?? 22; server.sshUser = body.sshUser; server.sshKeyEncrypted = encrypted.toString('base64'); server.sshKeyIv = iv.toString('base64'); server.maxInstances = body.maxInstances ?? 10; server.currentInstances = 0; server.status = 'active'; server.region = body.region; server.notes = body.notes; return this.serialize(await this.poolServerRepository.save(server)); } @Put(':id') async update(@Param('id') id: string, @Body() body: { name?: string; maxInstances?: number; status?: 'active' | 'maintenance' | 'offline'; region?: string; notes?: string; sshKey?: string; // optional re-key }) { const server = await this.poolServerRepository.findById(id); if (!server) throw new NotFoundException(`Pool server ${id} not found`); if (body.name !== undefined) server.name = body.name; if (body.maxInstances !== undefined) server.maxInstances = body.maxInstances; if (body.status !== undefined) server.status = body.status; if (body.region !== undefined) server.region = body.region; if (body.notes !== undefined) server.notes = body.notes; if (body.sshKey) { const { encrypted, iv } = this.vault.encrypt(body.sshKey); server.sshKeyEncrypted = encrypted.toString('base64'); server.sshKeyIv = iv.toString('base64'); } return this.serialize(await this.poolServerRepository.save(server)); } @Delete(':id') async remove(@Param('id') id: string) { const server = await this.poolServerRepository.findById(id); if (!server) throw new NotFoundException(`Pool server ${id} not found`); if (server.currentInstances > 0) { throw new BadRequestException( `Cannot delete server with ${server.currentInstances} active instance(s). Stop all instances first.`, ); } await this.poolServerRepository.remove(server); return { message: 'Pool server deleted', id }; } // Internal endpoint: called by agent-service to get decrypted SSH credentials for deployment @Get(':id/deploy-creds') async deployCreds( @Param('id') id: string, @Headers('x-internal-api-key') key: string, ) { if (!this.internalApiKey || key !== this.internalApiKey) { throw new UnauthorizedException('Invalid internal API key'); } const server = await this.poolServerRepository.findById(id); if (!server) throw new NotFoundException(`Pool server ${id} not found`); const sshKey = this.vault.decrypt( Buffer.from(server.sshKeyEncrypted, 'base64'), Buffer.from(server.sshKeyIv, 'base64'), ); return { id: server.id, host: server.host, sshPort: server.sshPort, sshUser: server.sshUser, sshKey, }; } // Internal endpoint: called by agent-service after deploying / removing an OpenClaw instance @Post(':id/increment') async increment(@Param('id') id: string) { const server = await this.poolServerRepository.findById(id); if (!server) throw new NotFoundException(`Pool server ${id} not found`); await this.poolServerRepository.incrementInstances(id); return { ok: true }; } @Post(':id/decrement') async decrement(@Param('id') id: string) { const server = await this.poolServerRepository.findById(id); if (!server) throw new NotFoundException(`Pool server ${id} not found`); await this.poolServerRepository.decrementInstances(id); return { ok: true }; } }