import { Router, Request, Response } from 'express'; import { AuditAction } from '@prisma/client'; import rateLimit from 'express-rate-limit'; import { prisma } from '../../lib/prisma'; import { authenticate, requireRole } from '../../middleware/auth'; import { validate } from '../../middleware/validate'; import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas'; import * as instancesService from './instances.service'; import * as healthService from '../../services/health.service'; import * as backupService from '../../services/backup.service'; import * as restoreService from '../../services/restore.service'; import * as upgradeService from '../../services/upgrade.service'; import * as tunnelService from '../../services/tunnel.service'; import { discoverInstances } from '../../services/discovery.service'; const secretsLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: { message: 'Too many secrets requests, please try again later', code: 'RATE_LIMITED' } }, }); const router = Router(); // All instance routes require authentication router.use(authenticate); // ─── Discovery Endpoints ──────────────────────────────────────────── router.post( '/discover', requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => { const result = await discoverInstances(); res.json({ data: result }); } ); router.post( '/import', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(importInstancesSchema), async (req: Request, res: Response) => { const { instances } = req.body as { instances: Array> }; const results: Array<{ slug: string; success: boolean; instanceId?: string; error?: string }> = []; for (const inst of instances) { try { const registered = await instancesService.registerInstance( inst as Parameters[0], req.user!.id, req.ip ); results.push({ slug: inst.slug as string, success: true, instanceId: registered?.id }); } catch (err) { results.push({ slug: inst.slug as string, success: false, error: (err as Error).message }); } } const succeeded = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; res.json({ data: { results, summary: { total: results.length, succeeded, failed }, }, }); } ); // ─── CRUD Endpoints ────────────────────────────────────────────────── router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => { const instances = await instancesService.listInstances(); res.json({ data: instances }); }); // Register an existing (externally-managed) instance for monitoring router.post( '/register', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(registerInstanceSchema), async (req: Request, res: Response) => { const instance = await instancesService.registerInstance(req.body, req.user!.id, req.ip); res.status(201).json({ data: instance }); } ); router.get('/:id', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const instance = await instancesService.getInstance(req.params.id as string); res.json({ data: instance }); }); router.post( '/', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(createInstanceSchema), async (req: Request, res: Response) => { const instance = await instancesService.createInstance(req.body, req.user!.id, req.ip); res.status(201).json({ data: instance }); } ); router.put( '/:id', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(updateInstanceSchema), async (req: Request, res: Response) => { const instance = await instancesService.updateInstance( req.params.id as string, req.body, req.user!.id, req.ip ); res.json({ data: instance }); } ); router.delete( '/:id', requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => { const result = await instancesService.deleteInstance(req.params.id as string, req.user!.id, req.ip); res.json(result); } ); // Get decrypted secrets (SUPER_ADMIN only, rate limited) router.get( '/:id/secrets', secretsLimiter, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => { const secrets = await instancesService.getInstanceSecrets(req.params.id as string); // Audit log: someone viewed secrets await prisma.auditLog.create({ data: { userId: req.user!.id, instanceId: req.params.id as string, action: AuditAction.SECRETS_VIEWED, details: { instanceId: req.params.id as string }, ipAddress: req.ip, }, }); res.set('Cache-Control', 'no-store'); res.json({ data: secrets }); } ); // ─── Reconfiguration ──────────────────────────────────────────────── router.post( '/:id/reconfigure', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(reconfigureInstanceSchema), async (req: Request, res: Response) => { const result = await instancesService.reconfigureInstance( req.params.id as string, req.body, req.user!.id, req.ip ); res.json({ data: result }); } ); // ─── Tunnel Management ────────────────────────────────────────────── router.post( '/:id/tunnel', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(configureTunnelSchema), async (req: Request, res: Response) => { const result = await instancesService.configureTunnel( req.params.id as string, req.body, req.user!.id, req.ip ); res.json({ data: result }); } ); router.delete( '/:id/tunnel', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { // Branch: remote instances use the CCP's Pangolin API to teardown; // local instances use the existing manual removal logic. const instance = await prisma.instance.findUnique({ where: { id: req.params.id as string } }); if (instance?.isRemote && instance.pangolinSiteId) { const result = await tunnelService.teardownTunnel( req.params.id as string, req.user!.id, req.ip ); res.json({ data: result }); return; } const result = await instancesService.removeTunnel( req.params.id as string, req.user!.id, req.ip ); res.json({ data: result }); } ); // Remote tunnel setup via CCP's Pangolin API credentials router.post( '/:id/tunnel/setup', requireRole('SUPER_ADMIN'), validate(setupRemoteTunnelSchema), async (req: Request, res: Response) => { const { subdomainPrefix } = req.body || {}; const result = await tunnelService.setupTunnel( req.params.id as string, { subdomainPrefix }, req.user!.id, req.ip ); res.status(201).json({ data: result }); } ); // Get tunnel status (resource matrix) — works for both local and remote router.get( '/:id/tunnel/status', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const status = await tunnelService.getTunnelStatus(req.params.id as string); res.json({ data: status }); } ); // Re-sync resources (idempotent — creates missing, leaves existing) router.post( '/:id/tunnel/sync', requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => { const result = await tunnelService.syncResources( req.params.id as string, req.user!.id, req.ip ); res.json({ data: result }); } ); // Adopt a tunnel that was set up outside CCP (e.g. by config.sh --pangolin-site new) router.post( '/:id/tunnel/import', requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => { const result = await tunnelService.importTunnel( req.params.id as string, req.user!.id, req.ip ); res.json({ data: result }); } ); // ─── Lifecycle Endpoints ───────────────────────────────────────────── router.post( '/:id/provision', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const result = await instancesService.provisionInstance(req.params.id as string, req.user!.id, req.ip); res.json(result); } ); router.post( '/:id/start', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const result = await instancesService.startInstance(req.params.id as string, req.user!.id, req.ip); res.json(result); } ); router.post( '/:id/stop', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const result = await instancesService.stopInstance(req.params.id as string, req.user!.id, req.ip); res.json(result); } ); router.post( '/:id/restart', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const service = req.query.service as string | undefined; const result = await instancesService.restartInstance( req.params.id as string, req.user!.id, req.ip, service ); res.json(result); } ); // ─── Services & Logs ───────────────────────────────────────────────── router.get( '/:id/services', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const services = await instancesService.getInstanceServices(req.params.id as string); res.json({ data: services }); } ); router.get( '/:id/logs', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const { service, tail, since } = req.query; const tailNum = tail ? Math.min(Math.max(parseInt(tail as string, 10) || 200, 1), 2000) : 200; const logs = await instancesService.getInstanceLogs( req.params.id as string, service as string | undefined, tailNum, since as string | undefined ); res.json({ data: logs }); } ); // ─── Upgrades ────────────────────────────────────────────────────── router.post( '/:id/check-update', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const status = await upgradeService.checkForUpdates(req.params.id as string); res.json({ data: status }); } ); router.post( '/:id/upgrade', requireRole('SUPER_ADMIN', 'OPERATOR'), validate(startUpgradeSchema), async (req: Request, res: Response) => { const { skipBackup, useRegistry, branch } = req.body || {}; const upgrade = await upgradeService.startUpgrade( req.params.id as string, req.user!.id, req.ip, { skipBackup, useRegistry, branch } ); res.status(201).json({ data: upgrade }); } ); router.get( '/:id/upgrade-status', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const status = await upgradeService.getUpgradeStatus(req.params.id as string); res.json({ data: status }); } ); router.get( '/:id/upgrade-history', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); const result = await upgradeService.getUpgradeHistory(req.params.id as string, page, limit); res.json(result); } ); // ─── Health Checks ────────────────────────────────────────────────── router.post( '/:id/health-check', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const check = await healthService.checkInstanceHealth(req.params.id as string); res.json({ data: check }); } ); router.get( '/:id/health-history', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); const result = await healthService.getHealthHistory(req.params.id as string, page, limit); res.json(result); } ); // ─── Backups ──────────────────────────────────────────────────────── router.post( '/:id/backup', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const backup = await backupService.createBackup(req.params.id as string, req.user!.id, req.ip); res.status(201).json({ data: backup }); } ); router.get( '/:id/backups', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50)); const result = await backupService.listBackups(req.params.id as string, page, limit); res.json(result); } ); // ─── Restores ────────────────────────────────────────────────────── /** * POST /:id/restore * Body: { backupId, options? } * Starts a restore of the given backup onto this instance. Returns the * InstanceRestore row immediately; caller polls GET /:id/restores or * GET /:id/restores/:restoreId for status. * * DESTRUCTIVE: overwrites databases and uploads. Requires SUPER_ADMIN. */ router.post( '/:id/restore', requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => { const instanceId = req.params.id as string; const { backupId, options } = req.body ?? {}; if (!backupId || typeof backupId !== 'string') { res.status(400).json({ error: { message: 'backupId (string) is required', code: 'VALIDATION' } }); return; } // Defensive: ensure the backup belongs to this instance const backup = await prisma.backup.findUnique({ where: { id: backupId } }); if (!backup) { res.status(404).json({ error: { message: 'Backup not found', code: 'NOT_FOUND' } }); return; } if (backup.instanceId !== instanceId) { res.status(400).json({ error: { message: 'Backup does not belong to this instance (cross-instance restore is not supported)', code: 'CROSS_INSTANCE_RESTORE', }, }); return; } const restore = await restoreService.createRestore({ backupId, triggeredById: req.user!.id, ipAddress: req.ip, options, }); res.status(201).json({ data: restore }); } ); router.get( '/:id/restores', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50)); const result = await restoreService.listRestores(req.params.id as string, page, limit); res.json(result); } ); router.get( '/:id/restores/:restoreId', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const restore = await restoreService.getRestore(req.params.restoreId as string); if (restore.instanceId !== req.params.id) { res.status(404).json({ error: { message: 'Restore not found', code: 'NOT_FOUND' } }); return; } res.json({ data: restore }); } ); export default router;