Enables the CCP to manage CML instances on remote servers via a lightweight HTTP agent. Key components: - ExecutionDriver abstraction (local-driver.ts / remote-driver.ts) routes operations to local Docker or remote agent transparently - Remote agent package (agent/) with mTLS authentication, Docker Compose operations, file management, backup/upgrade delegation - Certificate service using openssl CLI for CA management and cert issuance - Phone-home registration: remote agents register via invite code, CCP admin approves, agent receives mTLS cert bundle automatically - config.sh integration with configure_control_panel() section - ccp-agent Docker Compose service (profile-gated) - Frontend: AgentRegistrationsPage, InviteCodesPage, Remote Agents sidebar menu - Security hardened: cert bundle wiped after delivery, shell injection prevention via execFile, command allowlist with metachar rejection, rate-limited public endpoints, auto-populated fingerprint pinning Also wires ENABLE_SOCIAL/PEOPLE/ANALYTICS through env.ts, seed.ts, and docker-compose env passthrough (from previous session). Bunker Admin
249 lines
8.7 KiB
TypeScript
249 lines
8.7 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import rateLimit from 'express-rate-limit';
|
|
import { prisma } from '../../lib/prisma';
|
|
import { Prisma, AuditAction, InstanceStatus, AgentRegistrationStatus } from '@prisma/client';
|
|
import { validateInviteCode, markCodeUsed } from '../../services/invite-code.service';
|
|
import { issueAgentCert } from '../../services/certificate.service';
|
|
import { authenticate, requireRole } from '../../middleware/auth';
|
|
import { AppError } from '../../middleware/error-handler';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
const router = Router();
|
|
|
|
// SECURITY: Strict rate limiter for unauthenticated agent endpoints
|
|
const agentRegistrationLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 10, // 10 attempts per window per IP
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'RATE_LIMITED', message: 'Too many registration attempts, try again later' },
|
|
});
|
|
|
|
// ─── Public Endpoints (used by remote agents during phone-home) ──────
|
|
|
|
/**
|
|
* POST /api/agents/register
|
|
* Agent phones home with invite code + instance metadata.
|
|
* Creates a PENDING registration for admin approval.
|
|
*/
|
|
router.post('/register', agentRegistrationLimiter, async (req: Request, res: Response) => {
|
|
const { inviteCode, slug, name, domain, agentUrl, basePath, composeProject, metadata } = req.body;
|
|
|
|
if (!inviteCode || !slug || !agentUrl) {
|
|
throw new AppError(400, 'inviteCode, slug, and agentUrl are required');
|
|
}
|
|
|
|
// Validate invite code
|
|
const invite = await validateInviteCode(inviteCode);
|
|
|
|
// Check for duplicate pending registrations
|
|
const existing = await prisma.agentRegistration.findFirst({
|
|
where: { slug, status: AgentRegistrationStatus.PENDING },
|
|
});
|
|
if (existing) {
|
|
res.json({ registrationId: existing.id, status: 'PENDING' });
|
|
return;
|
|
}
|
|
|
|
// Create pending registration
|
|
const registration = await prisma.agentRegistration.create({
|
|
data: {
|
|
inviteCodeId: invite.id,
|
|
slug: slug || '',
|
|
name: name || slug || '',
|
|
domain: domain || '',
|
|
agentUrl,
|
|
basePath: basePath || '',
|
|
composeProject: composeProject || slug || '',
|
|
metadata: metadata || null,
|
|
},
|
|
});
|
|
|
|
logger.info(`[agents] New registration request: ${slug} from ${agentUrl} (invite: ${invite.code})`);
|
|
|
|
res.status(201).json({
|
|
registrationId: registration.id,
|
|
status: 'PENDING',
|
|
message: 'Registration submitted — waiting for admin approval',
|
|
});
|
|
});
|
|
|
|
/**
|
|
* GET /api/agents/poll
|
|
* Agent polls to check if registration was approved.
|
|
* Returns cert bundle on approval.
|
|
*/
|
|
router.get('/poll', agentRegistrationLimiter, async (req: Request, res: Response) => {
|
|
const { registrationId, slug } = req.query;
|
|
|
|
if (!registrationId && !slug) {
|
|
throw new AppError(400, 'registrationId or slug required');
|
|
}
|
|
|
|
const registration = await prisma.agentRegistration.findFirst({
|
|
where: registrationId
|
|
? { id: registrationId as string }
|
|
: { slug: slug as string, status: { in: [AgentRegistrationStatus.PENDING, AgentRegistrationStatus.APPROVED] } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
if (!registration) {
|
|
throw new AppError(404, 'Registration not found');
|
|
}
|
|
|
|
if (registration.status === AgentRegistrationStatus.APPROVED && registration.certBundle) {
|
|
// Return cert bundle — agent will save certs and restart with mTLS
|
|
const bundle = registration.certBundle;
|
|
|
|
// SECURITY: Wipe the cert bundle (contains private key) after first delivery.
|
|
// The agent gets one chance to retrieve it; after that it's gone from the DB.
|
|
await prisma.agentRegistration.update({
|
|
where: { id: registration.id },
|
|
data: { certBundle: Prisma.DbNull },
|
|
});
|
|
logger.info(`[agents] Cert bundle delivered and wiped for ${registration.slug}`);
|
|
|
|
res.json({
|
|
status: 'APPROVED',
|
|
certBundle: bundle,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (registration.status === AgentRegistrationStatus.APPROVED && !registration.certBundle) {
|
|
// Cert bundle was already delivered and wiped — agent must re-issue if it missed it
|
|
res.json({ status: 'APPROVED', certBundle: null, message: 'Certificate bundle already delivered. Contact admin to re-issue.' });
|
|
return;
|
|
}
|
|
|
|
if (registration.status === AgentRegistrationStatus.REJECTED) {
|
|
res.json({ status: 'REJECTED' });
|
|
return;
|
|
}
|
|
|
|
res.json({ status: 'PENDING' });
|
|
});
|
|
|
|
// ─── Authenticated Endpoints (CCP admin) ─────────────────────────────
|
|
|
|
/**
|
|
* GET /api/agents/registrations
|
|
* List all agent registrations (pending, approved, rejected).
|
|
*/
|
|
router.get('/registrations', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
|
|
const registrations = await prisma.agentRegistration.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
res.json(registrations);
|
|
});
|
|
|
|
/**
|
|
* POST /api/agents/registrations/:id/approve
|
|
* Approve a pending registration: issue certs, create Instance, mark approved.
|
|
*/
|
|
router.post('/registrations/:id/approve', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
|
|
const { id } = req.params;
|
|
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
|
|
if (!registration) throw new AppError(404, 'Registration not found');
|
|
if (registration.status !== AgentRegistrationStatus.PENDING) {
|
|
throw new AppError(400, `Registration is ${registration.status}, not PENDING`);
|
|
}
|
|
|
|
// Create the Instance record
|
|
const instance = await prisma.instance.create({
|
|
data: {
|
|
slug: registration.slug,
|
|
name: registration.name,
|
|
domain: registration.domain,
|
|
status: InstanceStatus.STOPPED,
|
|
statusMessage: 'Remote instance registered — agent connecting',
|
|
basePath: registration.basePath,
|
|
composeProject: registration.composeProject,
|
|
portConfig: (registration.metadata as Record<string, unknown>)?.portConfig || { api: 4000, admin: 3000, postgres: 5432, nginx: 80 },
|
|
isRegistered: true,
|
|
isRemote: true,
|
|
agentUrl: registration.agentUrl,
|
|
adminEmail: (registration.metadata as Record<string, unknown>)?.adminEmail as string || 'admin@example.com',
|
|
},
|
|
});
|
|
|
|
// Issue mTLS certificates
|
|
const certMaterials = await issueAgentCert(instance.id, registration.slug);
|
|
|
|
// Mark invite code as used
|
|
const invite = await prisma.agentInviteCode.findUnique({ where: { id: registration.inviteCodeId } });
|
|
if (invite && !invite.usedAt) {
|
|
await markCodeUsed(invite.code, instance.id);
|
|
}
|
|
|
|
// Update registration with approval + cert bundle
|
|
await prisma.agentRegistration.update({
|
|
where: { id: id as string },
|
|
data: {
|
|
status: AgentRegistrationStatus.APPROVED,
|
|
instanceId: instance.id,
|
|
approvedById: (req as unknown as { user: { id: string } }).user.id,
|
|
approvedAt: new Date(),
|
|
certBundle: {
|
|
caCertPem: certMaterials.caCertPem,
|
|
agentCertPem: certMaterials.agentCertPem,
|
|
agentKeyPem: certMaterials.agentKeyPem,
|
|
ccpFingerprint: certMaterials.caFingerprint,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Audit log
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: (req as unknown as { user: { id: string } }).user.id,
|
|
instanceId: instance.id,
|
|
action: AuditAction.AGENT_APPROVE,
|
|
details: { slug: registration.slug, agentUrl: registration.agentUrl },
|
|
ipAddress: req.ip || null,
|
|
},
|
|
});
|
|
|
|
logger.info(`[agents] Registration approved: ${registration.slug} → instance ${instance.id}`);
|
|
|
|
res.json({
|
|
message: 'Registration approved — agent will receive certificates on next poll',
|
|
instanceId: instance.id,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /api/agents/registrations/:id/reject
|
|
* Reject a pending registration.
|
|
*/
|
|
router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
|
|
const { id } = req.params;
|
|
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
|
|
if (!registration) throw new AppError(404, 'Registration not found');
|
|
if (registration.status !== AgentRegistrationStatus.PENDING) {
|
|
throw new AppError(400, `Registration is ${registration.status}, not PENDING`);
|
|
}
|
|
|
|
await prisma.agentRegistration.update({
|
|
where: { id: id as string },
|
|
data: {
|
|
status: AgentRegistrationStatus.REJECTED,
|
|
rejectedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: (req as unknown as { user: { id: string } }).user.id,
|
|
action: AuditAction.AGENT_REJECT,
|
|
details: { slug: registration.slug, agentUrl: registration.agentUrl },
|
|
ipAddress: req.ip || null,
|
|
},
|
|
});
|
|
|
|
res.json({ message: 'Registration rejected' });
|
|
});
|
|
|
|
export default router;
|