bunker-admin 721b4df6c3 Add remote instance management with mTLS agent and phone-home registration
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
2026-04-07 15:24:33 -06:00

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;