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
This commit is contained in:
2026-04-07 15:24:33 -06:00
parent d17e197a1b
commit 38ccaa8a5b
52 changed files with 4939 additions and 108 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "enable_analytics" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,111 @@
-- CreateEnum
CREATE TYPE "AgentRegistrationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'EXPIRED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_CONNECT';
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_REGISTER';
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_APPROVE';
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_REJECT';
ALTER TYPE "AuditAction" ADD VALUE 'INVITE_CREATE';
ALTER TYPE "AuditAction" ADD VALUE 'INVITE_REVOKE';
ALTER TYPE "AuditAction" ADD VALUE 'CERT_ISSUE';
ALTER TYPE "AuditAction" ADD VALUE 'CERT_REVOKE';
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "agent_fingerprint" TEXT,
ADD COLUMN "agent_last_seen" TIMESTAMP(3),
ADD COLUMN "agent_url" TEXT,
ADD COLUMN "agent_version" TEXT,
ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ccp_certificate_authority" (
"id" TEXT NOT NULL,
"common_name" TEXT NOT NULL,
"encrypted_key" TEXT NOT NULL,
"cert_pem" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ccp_certificate_authority_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "issued_agent_certs" (
"id" TEXT NOT NULL,
"ca_id" TEXT NOT NULL,
"instance_id" TEXT NOT NULL,
"common_name" TEXT NOT NULL,
"encrypted_key" TEXT NOT NULL,
"cert_pem" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
CONSTRAINT "issued_agent_certs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_invite_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"created_by_id" TEXT NOT NULL,
"used_by_id" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agent_invite_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_registrations" (
"id" TEXT NOT NULL,
"invite_code_id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"agent_url" TEXT NOT NULL,
"base_path" TEXT NOT NULL,
"compose_project" TEXT NOT NULL,
"metadata" JSONB,
"status" "AgentRegistrationStatus" NOT NULL DEFAULT 'PENDING',
"instance_id" TEXT,
"approved_by_id" TEXT,
"approved_at" TIMESTAMP(3),
"rejected_at" TIMESTAMP(3),
"cert_bundle" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agent_registrations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "issued_agent_certs_instance_id_key" ON "issued_agent_certs"("instance_id");
-- CreateIndex
CREATE INDEX "issued_agent_certs_instance_id_idx" ON "issued_agent_certs"("instance_id");
-- CreateIndex
CREATE UNIQUE INDEX "agent_invite_codes_code_key" ON "agent_invite_codes"("code");
-- CreateIndex
CREATE INDEX "agent_registrations_status_idx" ON "agent_registrations"("status");
-- AddForeignKey
ALTER TABLE "issued_agent_certs" ADD CONSTRAINT "issued_agent_certs_ca_id_fkey" FOREIGN KEY ("ca_id") REFERENCES "ccp_certificate_authority"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issued_agent_certs" ADD CONSTRAINT "issued_agent_certs_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_invite_codes" ADD CONSTRAINT "agent_invite_codes_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "ccp_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -28,6 +28,7 @@ model CcpUser {
auditLogs AuditLog[]
triggeredUpgrades InstanceUpgrade[]
acknowledgedEvents InstanceEvent[]
agentInviteCodes AgentInviteCode[]
@@map("ccp_users")
}
@@ -78,6 +79,13 @@ model Instance {
// True if this instance was registered externally (not provisioned by CCP)
isRegistered Boolean @default(false) @map("is_registered")
// Remote agent management
isRemote Boolean @default(false) @map("is_remote")
agentUrl String? @map("agent_url")
agentFingerprint String? @map("agent_fingerprint")
agentVersion String? @map("agent_version")
agentLastSeen DateTime? @map("agent_last_seen")
// Feature flags
enableMedia Boolean @default(false) @map("enable_media")
enableChat Boolean @default(false) @map("enable_chat")
@@ -120,6 +128,7 @@ model Instance {
auditLogs AuditLog[]
upgrades InstanceUpgrade[]
events InstanceEvent[]
agentCert IssuedAgentCert?
@@map("instances")
}
@@ -208,6 +217,14 @@ enum AuditAction {
BACKUP_DELETE
PANGOLIN_SETUP
PANGOLIN_SYNC
AGENT_CONNECT
AGENT_REGISTER
AGENT_APPROVE
AGENT_REJECT
INVITE_CREATE
INVITE_REVOKE
CERT_ISSUE
CERT_REVOKE
USER_LOGIN
USER_CREATE
USER_UPDATE
@@ -313,3 +330,81 @@ model CcpSetting {
@@map("ccp_settings")
}
// ─── Remote Agent Management ──────────────────────────────
model CcpCertificateAuthority {
id String @id @default(uuid())
commonName String @map("common_name")
encryptedKey String @map("encrypted_key")
certPem String @map("cert_pem")
fingerprint String
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
issuedCerts IssuedAgentCert[]
@@map("ccp_certificate_authority")
}
model IssuedAgentCert {
id String @id @default(uuid())
caId String @map("ca_id")
instanceId String @unique @map("instance_id")
commonName String @map("common_name")
encryptedKey String @map("encrypted_key")
certPem String @map("cert_pem")
fingerprint String
issuedAt DateTime @default(now()) @map("issued_at")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
ca CcpCertificateAuthority @relation(fields: [caId], references: [id])
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
@@index([instanceId])
@@map("issued_agent_certs")
}
model AgentInviteCode {
id String @id @default(uuid())
code String @unique
createdById String @map("created_by_id")
usedById String? @map("used_by_id")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
createdBy CcpUser @relation(fields: [createdById], references: [id])
@@map("agent_invite_codes")
}
enum AgentRegistrationStatus {
PENDING
APPROVED
REJECTED
EXPIRED
}
model AgentRegistration {
id String @id @default(uuid())
inviteCodeId String @map("invite_code_id")
slug String
name String
domain String
agentUrl String @map("agent_url")
basePath String @map("base_path")
composeProject String @map("compose_project")
metadata Json?
status AgentRegistrationStatus @default(PENDING)
instanceId String? @map("instance_id")
approvedById String? @map("approved_by_id")
approvedAt DateTime? @map("approved_at")
rejectedAt DateTime? @map("rejected_at")
certBundle Json? @map("cert_bundle")
createdAt DateTime @default(now()) @map("created_at")
@@index([status])
@@map("agent_registrations")
}

View File

@@ -62,6 +62,12 @@ const envSchema = z.object({
// Health checks
HEALTH_CHECK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 min (0 to disable)
// Remote agent defaults
AGENT_CONNECT_TIMEOUT_MS: z.coerce.number().default(10_000),
AGENT_REQUEST_TIMEOUT_MS: z.coerce.number().default(30_000),
AGENT_LONG_OP_TIMEOUT_MS: z.coerce.number().default(600_000), // 10 min for backups/builds
AGENT_HEALTH_FAILURE_THRESHOLD: z.coerce.number().default(3),
// Backups
BACKUP_STORAGE_PATH: z.string().default(
path.resolve(process.cwd(), '..', 'backups')

View File

@@ -0,0 +1,248 @@
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;

View File

@@ -0,0 +1,16 @@
import { Router, Request, Response } from 'express';
import { authenticate, requireRole } from '../../middleware/auth';
import { getCACert } from '../../services/certificate.service';
const router = Router();
/**
* GET /api/certificates/ca
* Get the CCP CA public certificate (for manual agent setup).
*/
router.get('/ca', authenticate, requireRole('SUPER_ADMIN'), async (_req: Request, res: Response) => {
const ca = await getCACert();
res.json(ca);
});
export default router;

View File

@@ -16,6 +16,7 @@ export const createInstanceSchema = z.object({
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
enableAnalytics: z.boolean().default(false),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
@@ -42,6 +43,7 @@ export const updateInstanceSchema = z.object({
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).nullable().optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
@@ -76,6 +78,7 @@ export const registerInstanceSchema = z.object({
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
enableAnalytics: z.boolean().default(false),
emailTestMode: z.boolean().default(true),
notes: z.string().optional(),
});
@@ -92,6 +95,7 @@ export const reconfigureInstanceSchema = z.object({
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
});
export const configureTunnelSchema = z.object({

View File

@@ -8,6 +8,7 @@ import { encryptJson, decryptJson } from '../../utils/encryption';
import { generateSecrets } from '../../services/secret-generator';
import { allocatePorts, releasePorts } from '../../services/port-allocator';
import * as docker from '../../services/docker.service';
import { getDriverForInstance, AgentUnreachableError } from '../../services/execution-driver';
import { provision } from './provisioner';
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput, ConfigureTunnelInput } from './instances.schemas';
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
@@ -86,6 +87,7 @@ export async function createInstance(input: CreateInstanceInput, userId: string,
enableSms: input.enableSms,
enableSocial: input.enableSocial,
enablePeople: input.enablePeople,
enableAnalytics: input.enableAnalytics,
jvbAdvertiseIp: input.jvbAdvertiseIp,
adminEmail: input.adminEmail,
pangolinEndpoint: input.enablePangolin ? input.pangolinEndpoint : null,
@@ -184,6 +186,7 @@ export async function registerInstance(input: RegisterInstanceInput, userId: str
enableSms: input.enableSms,
enableSocial: input.enableSocial,
enablePeople: input.enablePeople,
enableAnalytics: input.enableAnalytics,
adminEmail: input.adminEmail,
notes: input.notes,
},
@@ -282,7 +285,8 @@ export async function deleteInstance(id: string, userId: string, ipAddress?: str
// Stop containers and remove volumes
try {
await docker.composeDown(instance.basePath, instance.composeProject, true);
const driver = await getDriverForInstance(instance);
await driver.composeDown(instance.basePath, instance.composeProject, true);
logger.info(`[instances] ${instance.slug}: Containers stopped and volumes removed`);
} catch (err) {
logger.warn(`[instances] ${instance.slug}: Docker cleanup warning: ${(err as Error).message}`);
@@ -413,7 +417,8 @@ export async function startInstance(id: string, userId: string, ipAddress?: stri
}
try {
await docker.composeUp(instance.basePath, instance.composeProject);
const driver = await getDriverForInstance(instance);
await driver.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
@@ -432,6 +437,13 @@ export async function startInstance(id: string, userId: string, ipAddress?: stri
return { message: 'Instance started' };
} catch (err) {
if (err instanceof AgentUnreachableError) {
await prisma.instance.update({
where: { id },
data: { status: InstanceStatus.ERROR, statusMessage: `Agent unreachable: ${err.agentUrl}` },
});
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
@@ -452,7 +464,8 @@ export async function stopInstance(id: string, userId: string, ipAddress?: strin
}
try {
await docker.composeStop(instance.basePath, instance.composeProject);
const driver = await getDriverForInstance(instance);
await driver.composeStop(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
@@ -471,6 +484,9 @@ export async function stopInstance(id: string, userId: string, ipAddress?: strin
return { message: 'Instance stopped' };
} catch (err) {
if (err instanceof AgentUnreachableError) {
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to stop instance: ${errorMsg}`, 'DOCKER_ERROR');
}
@@ -483,7 +499,8 @@ export async function restartInstance(id: string, userId: string, ipAddress?: st
}
try {
await docker.composeRestart(instance.basePath, instance.composeProject, service);
const driver = await getDriverForInstance(instance);
await driver.composeRestart(instance.basePath, instance.composeProject, service);
await prisma.auditLog.create({
data: {
@@ -497,6 +514,9 @@ export async function restartInstance(id: string, userId: string, ipAddress?: st
return { message: `${service || 'All services'} restarted` };
} catch (err) {
if (err instanceof AgentUnreachableError) {
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to restart: ${errorMsg}`, 'DOCKER_ERROR');
}
@@ -509,9 +529,10 @@ export async function getInstanceServices(id: string) {
}
try {
return await docker.composePs(instance.basePath, instance.composeProject);
const driver = await getDriverForInstance(instance);
return await driver.composePs(instance.basePath, instance.composeProject);
} catch {
// If compose ps fails (e.g. no containers), return empty array
// If compose ps fails (e.g. no containers or agent unreachable), return empty array
return [];
}
}
@@ -528,7 +549,8 @@ export async function getInstanceLogs(
}
try {
return await docker.composeLogs(
const driver = await getDriverForInstance(instance);
return await driver.composeLogs(
instance.basePath,
instance.composeProject,
service,
@@ -536,6 +558,9 @@ export async function getInstanceLogs(
since
);
} catch (err) {
if (err instanceof AgentUnreachableError) {
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
throw new AppError(500, `Failed to get logs: ${(err as Error).message}`, 'DOCKER_ERROR');
}
}
@@ -577,12 +602,21 @@ export async function reconfigureInstance(
// Re-render templates with updated flags
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
await renderAllTemplates(context, instance.basePath);
const driver = await getDriverForInstance(instance);
if (instance.isRemote) {
// Remote: render in memory, send files to agent
const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
const files = await renderAllTemplatesInMemory(context);
await driver.writeFiles(instance.basePath, files);
} else {
await renderAllTemplates(context, instance.basePath);
}
// If instance is running, apply changes via docker compose up
if (instance.status === 'RUNNING') {
try {
await docker.composeUp(instance.basePath, instance.composeProject);
await driver.composeUp(instance.basePath, instance.composeProject);
// --remove-orphans (from composeUp) will clean up disabled services
await prisma.instance.update({
@@ -590,6 +624,13 @@ export async function reconfigureInstance(
data: { statusMessage: 'Reconfiguration complete' },
});
} catch (err) {
if (err instanceof AgentUnreachableError) {
await prisma.instance.update({
where: { id },
data: { statusMessage: `Agent unreachable: ${(err as AgentUnreachableError).agentUrl}` },
});
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
@@ -661,12 +702,20 @@ export async function configureTunnel(
clearTemplateCache();
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
await renderAllTemplates(context, instance.basePath);
const driver = await getDriverForInstance(instance);
if (instance.isRemote) {
const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
const files = await renderAllTemplatesInMemory(context);
await driver.writeFiles(instance.basePath, files);
} else {
await renderAllTemplates(context, instance.basePath);
}
// If running, bring up the newt container
if (instance.status === 'RUNNING') {
try {
await docker.composeUp(instance.basePath, instance.composeProject, ['newt']);
await driver.composeUp(instance.basePath, instance.composeProject, ['newt']);
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Tunnel configured and Newt started' },
@@ -738,12 +787,20 @@ export async function removeTunnel(
clearTemplateCache();
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
await renderAllTemplates(context, instance.basePath);
const driver = await getDriverForInstance(instance);
if (instance.isRemote) {
const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
const files = await renderAllTemplatesInMemory(context);
await driver.writeFiles(instance.basePath, files);
} else {
await renderAllTemplates(context, instance.basePath);
}
// If running, full compose up with --remove-orphans removes the orphaned newt container
if (instance.status === 'RUNNING') {
try {
await docker.composeUp(instance.basePath, instance.composeProject);
await driver.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Tunnel removed' },

View File

@@ -0,0 +1,62 @@
import { Router, Request, Response } from 'express';
import { authenticate, requireRole } from '../../middleware/auth';
import { AuditAction } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { createInviteCode, listInviteCodes, revokeInviteCode } from '../../services/invite-code.service';
const router = Router();
/**
* POST /api/invite-codes
* Generate a new invite code for agent registration.
*/
router.post('/', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const userId = (req as unknown as { user: { id: string } }).user.id;
const { expiryHours } = req.body || {};
const invite = await createInviteCode(userId, expiryHours);
await prisma.auditLog.create({
data: {
userId,
action: AuditAction.INVITE_CREATE,
details: { code: invite.code, expiresAt: invite.expiresAt.toISOString() },
ipAddress: req.ip || null,
},
});
res.status(201).json(invite);
});
/**
* GET /api/invite-codes
* List all invite codes.
*/
router.get('/', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 50;
const result = await listInviteCodes(page, limit);
res.json(result);
});
/**
* DELETE /api/invite-codes/:id
* Revoke an unused invite code.
*/
router.delete('/:id', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const userId = (req as unknown as { user: { id: string } }).user.id;
await revokeInviteCode(req.params.id as string);
await prisma.auditLog.create({
data: {
userId,
action: AuditAction.INVITE_REVOKE,
details: { inviteCodeId: req.params.id },
ipAddress: req.ip || null,
},
});
res.json({ message: 'Invite code revoked' });
});
export default router;

View File

@@ -16,6 +16,9 @@ import healthRoutes from './modules/health/health.routes';
import auditRoutes from './modules/audit/audit.routes';
import backupRoutes from './modules/backups/backup.routes';
import eventsRoutes, { instanceEventsRouter } from './modules/events/events.routes';
import agentRoutes from './modules/agents/agents.routes';
import certificateRoutes from './modules/certificates/certificates.routes';
import inviteCodeRoutes from './modules/invite-codes/invite-codes.routes';
import { startHealthScheduler } from './services/health.service';
import { autoDiscoverOnStartup } from './services/discovery.service';
@@ -60,6 +63,9 @@ app.use('/api/audit', auditRoutes);
app.use('/api/backups', backupRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/instances/:id/events', instanceEventsRouter);
app.use('/api/agents', agentRoutes);
app.use('/api/certificates', certificateRoutes);
app.use('/api/invite-codes', inviteCodeRoutes);
// Error handler (must be last)
app.use(errorHandler);

View File

@@ -0,0 +1,236 @@
import crypto from 'crypto';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { prisma } from '../lib/prisma';
import { encrypt, decrypt } from '../utils/encryption';
import { logger } from '../utils/logger';
const exec = promisify(execCb);
const CA_VALIDITY_DAYS = 3650; // ~10 years
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
function computeFingerprint(certPem: string): string {
const der = Buffer.from(
certPem
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s/g, ''),
'base64'
);
return crypto.createHash('sha256').update(der).digest('hex');
}
/**
* Run openssl commands in a temp directory, then clean up.
*/
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ccp-cert-'));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
/**
* Ensure a Certificate Authority exists. Creates one if none exists.
*/
export async function ensureCA() {
const existing = await prisma.ccpCertificateAuthority.findFirst({
orderBy: { createdAt: 'desc' },
});
if (existing && existing.expiresAt > new Date()) {
return existing;
}
logger.info('Generating new CCP Certificate Authority...');
const { keyPem, certPem } = await withTempDir(async (dir) => {
const keyFile = path.join(dir, 'ca.key');
const certFile = path.join(dir, 'ca.crt');
// Generate CA key + self-signed cert
await exec(
`openssl req -x509 -newkey rsa:4096 -keyout "${keyFile}" -out "${certFile}" ` +
`-days ${CA_VALIDITY_DAYS} -nodes ` +
`-subj "/CN=CCP Certificate Authority/O=Changemaker Control Panel"`,
{ timeout: 30_000 }
);
return {
keyPem: await fs.readFile(keyFile, 'utf-8'),
certPem: await fs.readFile(certFile, 'utf-8'),
};
});
const fingerprint = computeFingerprint(certPem);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + CA_VALIDITY_DAYS);
const ca = await prisma.ccpCertificateAuthority.create({
data: {
commonName: 'CCP Certificate Authority',
encryptedKey: encrypt(keyPem),
certPem,
fingerprint,
expiresAt,
},
});
logger.info(`CA created: fingerprint=${fingerprint.substring(0, 16)}...`);
return ca;
}
/**
* Issue a certificate for a remote agent, signed by the CA.
* Returns the certificate materials (plaintext) for one-time display.
*/
export async function issueAgentCert(instanceId: string, slug: string) {
const ca = await ensureCA();
const caKeyPem = decrypt(ca.encryptedKey);
const commonName = `ccp-agent-${slug}`;
const { agentKeyPem, agentCertPem } = await withTempDir(async (dir) => {
const caKeyFile = path.join(dir, 'ca.key');
const caCertFile = path.join(dir, 'ca.crt');
const agentKeyFile = path.join(dir, 'agent.key');
const agentCsrFile = path.join(dir, 'agent.csr');
const agentCertFile = path.join(dir, 'agent.crt');
const serialFile = path.join(dir, 'serial');
const extFile = path.join(dir, 'ext.cnf');
// Write CA materials
await fs.writeFile(caKeyFile, caKeyPem);
await fs.writeFile(caCertFile, ca.certPem);
await fs.writeFile(serialFile, crypto.randomBytes(16).toString('hex'));
// Extensions for server+client auth
await fs.writeFile(extFile, [
'basicConstraints=CA:FALSE',
'keyUsage=digitalSignature,keyEncipherment',
'extendedKeyUsage=serverAuth,clientAuth',
].join('\n'));
// Generate agent key
await exec(
`openssl genrsa -out "${agentKeyFile}" 2048`,
{ timeout: 15_000 }
);
// Generate CSR
await exec(
`openssl req -new -key "${agentKeyFile}" -out "${agentCsrFile}" ` +
`-subj "/CN=${commonName}/O=Changemaker Lite Agent"`,
{ timeout: 15_000 }
);
// Sign CSR with CA
await exec(
`openssl x509 -req -in "${agentCsrFile}" ` +
`-CA "${caCertFile}" -CAkey "${caKeyFile}" ` +
`-CAserial "${serialFile}" ` +
`-out "${agentCertFile}" -days ${AGENT_CERT_VALIDITY_DAYS} ` +
`-extfile "${extFile}"`,
{ timeout: 15_000 }
);
return {
agentKeyPem: await fs.readFile(agentKeyFile, 'utf-8'),
agentCertPem: await fs.readFile(agentCertFile, 'utf-8'),
};
});
const fingerprint = computeFingerprint(agentCertPem);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + AGENT_CERT_VALIDITY_DAYS);
// Revoke any existing cert for this instance
await prisma.issuedAgentCert.deleteMany({ where: { instanceId } });
// Store the issued cert
await prisma.issuedAgentCert.create({
data: {
caId: ca.id,
instanceId,
commonName,
encryptedKey: encrypt(agentKeyPem),
certPem: agentCertPem,
fingerprint,
expiresAt,
},
});
// Update instance with the agent fingerprint
await prisma.instance.update({
where: { id: instanceId },
data: { agentFingerprint: fingerprint },
});
logger.info(`Agent cert issued for ${slug}: fingerprint=${fingerprint.substring(0, 16)}...`);
return {
caCertPem: ca.certPem,
agentCertPem,
agentKeyPem, // Plaintext — display once, never retrievable again
fingerprint,
caFingerprint: ca.fingerprint,
};
}
/**
* Revoke an agent's certificate.
*/
export async function revokeAgentCert(instanceId: string) {
const cert = await prisma.issuedAgentCert.findUnique({ where: { instanceId } });
if (!cert) return;
await prisma.issuedAgentCert.update({
where: { id: cert.id },
data: { revokedAt: new Date() },
});
await prisma.instance.update({
where: { id: instanceId },
data: { agentFingerprint: null },
});
logger.info(`Agent cert revoked for instance ${instanceId}`);
}
/**
* Get the mTLS materials CCP needs to present when calling a remote agent.
*/
export async function getAgentClientMaterials(instanceId: string) {
const cert = await prisma.issuedAgentCert.findUnique({
where: { instanceId },
include: { ca: true },
});
if (!cert || cert.revokedAt) return null;
return {
agentCertPem: cert.certPem,
agentKeyPem: decrypt(cert.encryptedKey),
caCertPem: cert.ca.certPem,
fingerprint: cert.fingerprint,
expiresAt: cert.expiresAt,
};
}
/**
* Get the CA public certificate (for manual agent setup).
*/
export async function getCACert() {
const ca = await ensureCA();
return {
certPem: ca.certPem,
fingerprint: ca.fingerprint,
expiresAt: ca.expiresAt,
};
}

View File

@@ -32,6 +32,7 @@ export interface DiscoveredInstance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
emailTestMode: boolean;
// Discovery metadata (UI-only, not persisted)
source: 'parent' | 'docker';
@@ -388,6 +389,7 @@ export async function autoDiscoverOnStartup(): Promise<void> {
enableSms: inst.enableSms,
enableSocial: inst.enableSocial,
enablePeople: inst.enablePeople,
enableAnalytics: inst.enableAnalytics,
emailTestMode: inst.emailTestMode,
},
userId,

View File

@@ -0,0 +1,82 @@
import type { ContainerInfo } from './docker.service';
/**
* Abstraction layer for instance operations.
* LocalDriver wraps docker.service.ts + filesystem.
* RemoteDriver makes HTTPS calls to the remote agent.
*/
export interface ExecutionDriver {
// ─── Docker Compose Operations ──────────────────────────────
composeUp(projectDir: string, project: string, services?: string[]): Promise<string>;
composeDown(projectDir: string, project: string, removeVolumes?: boolean): Promise<string>;
composeStop(projectDir: string, project: string): Promise<string>;
composeRestart(projectDir: string, project: string, service?: string): Promise<string>;
composePull(projectDir: string, project: string): Promise<string>;
composeBuild(projectDir: string, project: string): Promise<string>;
composePs(projectDir: string, project: string): Promise<ContainerInfo[]>;
composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string): Promise<string>;
composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>): Promise<string>;
// ─── Container Health ───────────────────────────────────────
waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number): Promise<boolean>;
waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number): Promise<boolean>;
// ─── Filesystem Operations ──────────────────────────────────
readEnvFile(basePath: string): Promise<Record<string, string> | null>;
writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void>;
mkdir(basePath: string, relativePath: string): Promise<void>;
fileExists(basePath: string, relativePath: string): Promise<boolean>;
deleteDirectory(dirPath: string): Promise<void>;
cloneSource(basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise<void>;
}
/**
* Error thrown when a remote agent is unreachable.
*/
export class AgentUnreachableError extends Error {
constructor(public agentUrl: string, cause?: Error) {
super(`Remote agent at ${agentUrl} is not reachable`);
this.name = 'AgentUnreachableError';
if (cause) this.cause = cause;
}
}
/**
* Minimal instance shape needed to resolve a driver.
*/
export interface DriverInstance {
id: string;
slug: string;
isRemote: boolean;
agentUrl: string | null;
}
/**
* Resolve the correct execution driver for an instance.
* Returns LocalDriver for local instances, RemoteDriver for remote ones.
*/
export async function getDriverForInstance(instance: DriverInstance): Promise<ExecutionDriver> {
if (!instance.isRemote) {
const { getLocalDriver } = await import('./local-driver');
return getLocalDriver();
}
if (!instance.agentUrl) {
throw new Error(`Remote instance ${instance.slug} has no agent URL configured`);
}
const { getAgentClientMaterials } = await import('./certificate.service');
const materials = await getAgentClientMaterials(instance.id);
if (!materials) {
throw new Error(`No valid certificate found for remote instance ${instance.slug}`);
}
const { RemoteDriver } = await import('./remote-driver');
return new RemoteDriver(
instance.agentUrl,
instance.slug,
Buffer.from(materials.agentCertPem),
Buffer.from(materials.agentKeyPem),
Buffer.from(materials.caCertPem)
);
}

View File

@@ -1,6 +1,10 @@
import fs from 'fs/promises';
import path from 'path';
import { parse as parseDotenv } from 'dotenv';
import { InstanceStatus, HealthStatus } from '@prisma/client';
import { prisma } from '../lib/prisma';
import * as docker from './docker.service';
import { getDriverForInstance, AgentUnreachableError } from './execution-driver';
import { logger } from '../utils/logger';
import { createEvent } from './event.service';
import type { ContainerInfo } from './docker.service';
@@ -52,8 +56,43 @@ function determineHealth(containers: ContainerInfo[]): {
return { status, serviceStatus, totalServices: total, healthyServices: healthyCount };
}
/**
* Parse an instance's .env file and return all variables.
* Returns null if the file doesn't exist or can't be read.
*/
async function readEnvFile(basePath: string): Promise<Record<string, string> | null> {
try {
const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
return parseDotenv(Buffer.from(content));
} catch {
return null;
}
}
/**
* Extract feature flags from parsed .env variables.
*/
function extractFeatureFlags(envVars: Record<string, string>): Record<string, boolean> {
const isTrue = (val?: string) => val?.toLowerCase() === 'true';
return {
enableMedia: isTrue(envVars.ENABLE_MEDIA_FEATURES),
enableChat: isTrue(envVars.ENABLE_CHAT),
enableGancio: isTrue(envVars.GANCIO_SYNC_ENABLED),
enableListmonk: isTrue(envVars.LISTMONK_SYNC_ENABLED),
enablePayments: isTrue(envVars.ENABLE_PAYMENTS),
enableMeet: isTrue(envVars.ENABLE_MEET),
enableSms: isTrue(envVars.ENABLE_SMS),
enableSocial: isTrue(envVars.ENABLE_SOCIAL),
enablePeople: isTrue(envVars.ENABLE_PEOPLE),
enableAnalytics: isTrue(envVars.ENABLE_ANALYTICS),
};
}
/**
* Check the health of a single instance. Returns the created HealthCheck record.
* Also auto-corrects instance.status based on actual container state:
* - RUNNING instance with 0 containers → STOPPED
* - STOPPED instance with running containers → RUNNING
*/
export async function checkInstanceHealth(instanceId: string) {
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
@@ -61,17 +100,29 @@ export async function checkInstanceHealth(instanceId: string) {
throw new Error(`Instance ${instanceId} not found`);
}
if (instance.status !== InstanceStatus.RUNNING) {
throw new Error(`Instance ${instance.slug} is not running (status: ${instance.status})`);
// Only check RUNNING or STOPPED instances (skip PROVISIONING, ERROR, DESTROYING)
if (instance.status !== InstanceStatus.RUNNING && instance.status !== InstanceStatus.STOPPED) {
throw new Error(`Instance ${instance.slug} is not checkable (status: ${instance.status})`);
}
const startTime = Date.now();
let containers: ContainerInfo[];
const driver = await getDriverForInstance(instance);
try {
containers = await docker.composePs(instance.basePath, instance.composeProject);
containers = await driver.composePs(instance.basePath, instance.composeProject);
} catch (err) {
// If compose ps fails, record UNKNOWN status
const updateData: { lastHealthCheck: Date; status?: InstanceStatus } = {
lastHealthCheck: new Date(),
};
// If we thought it was RUNNING but can't even reach compose, mark as STOPPED
if (instance.status === InstanceStatus.RUNNING) {
updateData.status = InstanceStatus.STOPPED;
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (compose ps failed)`);
}
const healthCheck = await prisma.healthCheck.create({
data: {
instanceId,
@@ -85,7 +136,7 @@ export async function checkInstanceHealth(instanceId: string) {
await prisma.instance.update({
where: { id: instanceId },
data: { lastHealthCheck: new Date() },
data: updateData,
});
logger.warn(`[health] ${instance.slug}: compose ps failed: ${(err as Error).message}`);
@@ -95,6 +146,62 @@ export async function checkInstanceHealth(instanceId: string) {
const responseTimeMs = Date.now() - startTime;
const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers);
// Auto-correct instance status based on actual container state
const hasRunningContainers = containers.some((c) => c.state === 'running');
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.STOPPED },
});
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.RUNNING },
});
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
}
// Sync domain and feature flags from .env if they have drifted
const envVars = instance.isRemote
? await driver.readEnvFile(instance.basePath)
: await readEnvFile(instance.basePath);
if (envVars) {
const driftUpdates: Record<string, unknown> = {};
// Domain sync
const envDomain = envVars.DOMAIN;
if (envDomain && envDomain !== instance.domain) {
driftUpdates.domain = envDomain;
logger.info(`[health] ${instance.slug}: synced domain ${instance.domain}${envDomain}`);
}
// Feature flag sync (only for registered/external instances)
if (instance.isRegistered) {
const envFlags = extractFeatureFlags(envVars);
const flagKeys = Object.keys(envFlags) as Array<keyof typeof envFlags>;
for (const key of flagKeys) {
if ((instance as Record<string, unknown>)[key] !== envFlags[key]) {
driftUpdates[key] = envFlags[key];
}
}
if (Object.keys(driftUpdates).length > (envDomain && envDomain !== instance.domain ? 1 : 0)) {
const changedFlags = flagKeys.filter(k => (instance as Record<string, unknown>)[k] !== envFlags[k]);
if (changedFlags.length > 0) {
logger.info(`[health] ${instance.slug}: synced feature flags: ${changedFlags.join(', ')}`);
}
}
}
if (Object.keys(driftUpdates).length > 0) {
await prisma.instance.update({
where: { id: instanceId },
data: driftUpdates,
});
}
}
// Get the previous health check to detect transitions
const previousCheck = await prisma.healthCheck.findFirst({
where: { instanceId },
@@ -113,9 +220,13 @@ export async function checkInstanceHealth(instanceId: string) {
},
});
const healthUpdateData: Record<string, unknown> = { lastHealthCheck: new Date() };
if (instance.isRemote) {
healthUpdateData.agentLastSeen = new Date();
}
await prisma.instance.update({
where: { id: instanceId },
data: { lastHealthCheck: new Date() },
data: healthUpdateData,
});
// Create events on health transitions
@@ -160,16 +271,17 @@ export async function checkInstanceHealth(instanceId: string) {
}
/**
* Check all running instances sequentially.
* Check all checkable instances (RUNNING + STOPPED) sequentially.
* STOPPED instances are checked so we can detect when they come back online.
*/
export async function checkAllInstances(): Promise<void> {
const instances = await prisma.instance.findMany({
where: { status: InstanceStatus.RUNNING },
select: { id: true, slug: true },
where: { status: { in: [InstanceStatus.RUNNING, InstanceStatus.STOPPED] } },
select: { id: true, slug: true, status: true },
});
if (instances.length === 0) {
logger.debug('[health] No running instances to check');
logger.debug('[health] No checkable instances');
return;
}

View File

@@ -0,0 +1,122 @@
import crypto from 'crypto';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/error-handler';
const CODE_LENGTH = 8; // e.g., "A3X7-K9M2"
const DEFAULT_EXPIRY_HOURS = 24;
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I,O,0,1 to avoid confusion
const bytes = crypto.randomBytes(CODE_LENGTH);
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
code += chars[bytes[i] % chars.length];
}
// Format as XXXX-XXXX
return `${code.slice(0, 4)}-${code.slice(4)}`;
}
/**
* Generate a single-use invite code for agent registration.
*/
export async function createInviteCode(userId: string, expiryHours = DEFAULT_EXPIRY_HOURS) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + expiryHours);
// Retry up to 3 times in case of code collision (extremely unlikely)
for (let attempt = 0; attempt < 3; attempt++) {
const code = generateCode();
try {
return await prisma.agentInviteCode.create({
data: {
code,
createdById: userId,
expiresAt,
},
});
} catch (err: unknown) {
const prismaError = err as { code?: string };
if (prismaError.code === 'P2002' && attempt < 2) continue; // unique constraint, retry
throw err;
}
}
throw new AppError(500, 'Failed to generate unique invite code');
}
/**
* Validate an invite code. Returns the code record if valid.
* Throws if expired, already used, or not found.
*/
export async function validateInviteCode(code: string) {
const normalized = code.toUpperCase().trim();
const invite = await prisma.agentInviteCode.findUnique({
where: { code: normalized },
});
if (!invite) {
throw new AppError(404, 'Invalid invite code', 'INVALID_CODE');
}
if (invite.usedAt) {
throw new AppError(400, 'Invite code has already been used', 'CODE_USED');
}
if (invite.expiresAt < new Date()) {
throw new AppError(400, 'Invite code has expired', 'CODE_EXPIRED');
}
return invite;
}
/**
* Mark an invite code as used by an instance.
*/
export async function markCodeUsed(code: string, instanceId: string) {
const normalized = code.toUpperCase().trim();
await prisma.agentInviteCode.update({
where: { code: normalized },
data: {
usedAt: new Date(),
usedById: instanceId,
},
});
}
/**
* List all invite codes with optional filtering.
*/
export async function listInviteCodes(page = 1, limit = 50) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
prisma.agentInviteCode.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, name: true, email: true } },
},
}),
prisma.agentInviteCode.count(),
]);
return { data, total, page, limit };
}
/**
* Revoke (delete) an unused invite code.
*/
export async function revokeInviteCode(codeId: string) {
const invite = await prisma.agentInviteCode.findUnique({ where: { id: codeId } });
if (!invite) {
throw new AppError(404, 'Invite code not found');
}
if (invite.usedAt) {
throw new AppError(400, 'Cannot revoke a code that has already been used');
}
await prisma.agentInviteCode.delete({ where: { id: codeId } });
}

View File

@@ -0,0 +1,130 @@
import fs from 'fs/promises';
import path from 'path';
import { promisify } from 'util';
import { parse as parseDotenv } from 'dotenv';
import * as docker from './docker.service';
import type { ExecutionDriver } from './execution-driver';
import { logger } from '../utils/logger';
/**
* LocalDriver wraps existing docker.service.ts functions and filesystem operations.
* This is a zero-behavior-change adapter — all existing local instance operations
* pass through unchanged.
*/
export class LocalDriver implements ExecutionDriver {
// ─── Docker Compose Operations ──────────────────────────────
composeUp(projectDir: string, project: string, services?: string[]) {
return docker.composeUp(projectDir, project, services);
}
composeDown(projectDir: string, project: string, removeVolumes?: boolean) {
return docker.composeDown(projectDir, project, removeVolumes);
}
composeStop(projectDir: string, project: string) {
return docker.composeStop(projectDir, project);
}
composeRestart(projectDir: string, project: string, service?: string) {
return docker.composeRestart(projectDir, project, service);
}
composePull(projectDir: string, project: string) {
return docker.composePull(projectDir, project);
}
composeBuild(projectDir: string, project: string) {
return docker.composeBuild(projectDir, project);
}
composePs(projectDir: string, project: string) {
return docker.composePs(projectDir, project);
}
composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string) {
return docker.composeLogs(projectDir, project, service, tail, since);
}
composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>) {
return docker.composeExec(projectDir, project, service, command, timeoutMs, envVars);
}
// ─── Container Health ───────────────────────────────────────
waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number) {
return docker.waitForHealthy(containerName, timeoutMs, pollIntervalMs);
}
waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number) {
return docker.waitForHttp(url, timeoutMs, pollIntervalMs);
}
// ─── Filesystem Operations ──────────────────────────────────
async readEnvFile(basePath: string): Promise<Record<string, string> | null> {
try {
const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
return parseDotenv(Buffer.from(content));
} catch {
return null;
}
}
async writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) {
for (const file of files) {
const filePath = path.join(basePath, file.relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, file.content, 'utf-8');
logger.debug(`[local-driver] Wrote ${filePath}`);
}
}
async mkdir(basePath: string, relativePath: string) {
await fs.mkdir(path.join(basePath, relativePath), { recursive: true });
}
async fileExists(basePath: string, relativePath: string): Promise<boolean> {
try {
await fs.access(path.join(basePath, relativePath));
return true;
} catch {
return false;
}
}
async deleteDirectory(dirPath: string) {
await fs.rm(dirPath, { recursive: true, force: true });
}
async cloneSource(basePath: string, _gitRepo: string, _gitBranch: string, excludes?: string[]) {
// Local provisioning uses rsync from CML_SOURCE_PATH
const { CML_SOURCE_PATH } = await import('../config/env').then((m) => m.env);
if (!CML_SOURCE_PATH) {
throw new Error('CML_SOURCE_PATH not configured — cannot clone source');
}
// SECURITY: Validate exclude entries — reject anything with shell metacharacters
const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
const safeExcludes = (excludes || [
'node_modules', '.git', '.env', 'changemaker-control-panel', '.claude',
'api/dist', 'admin/dist', 'uploads', 'data',
]).filter((e) => SAFE_EXCLUDE.test(e));
// SECURITY: Use execFile with args array — no shell interpolation
const { execFile: execFileCb } = await import('child_process');
const execFileAsync = promisify(execFileCb);
const args = ['-a', ...safeExcludes.flatMap((e) => ['--exclude', e]), `${CML_SOURCE_PATH}/`, `${basePath}/`];
await execFileAsync('rsync', args, { timeout: 120_000 });
}
}
/** Singleton local driver instance. */
let _localDriver: LocalDriver | null = null;
export function getLocalDriver(): LocalDriver {
if (!_localDriver) {
_localDriver = new LocalDriver();
}
return _localDriver;
}

View File

@@ -0,0 +1,264 @@
import https from 'https';
import { env } from '../config/env';
import type { ExecutionDriver } from './execution-driver';
import { AgentUnreachableError } from './execution-driver';
import type { ContainerInfo } from './docker.service';
import { logger } from '../utils/logger';
interface AgentRequestOptions {
method: 'GET' | 'POST' | 'DELETE';
path: string;
body?: unknown;
timeoutMs?: number;
}
/**
* RemoteDriver makes HTTPS calls to a remote CCP agent for all operations.
* Uses mTLS — both CCP (client) and agent (server) present certificates.
*/
export class RemoteDriver implements ExecutionDriver {
constructor(
private agentUrl: string,
private slug: string,
private clientCert: Buffer,
private clientKey: Buffer,
private caCert: Buffer
) {}
// ─── HTTP Client ────────────────────────────────────────────
private async request<T = unknown>(opts: AgentRequestOptions): Promise<T> {
const url = new URL(opts.path, this.agentUrl);
const timeoutMs = opts.timeoutMs || env.AGENT_REQUEST_TIMEOUT_MS;
const payload = opts.body ? JSON.stringify(opts.body) : undefined;
return new Promise<T>((resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: url.port || 7443,
path: url.pathname + url.search,
method: opts.method,
headers: {
'Content-Type': 'application/json',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
cert: this.clientCert,
key: this.clientKey,
ca: this.caCert,
rejectUnauthorized: true,
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
try {
const err = JSON.parse(data);
reject(new Error(err.message || `Agent returned ${res.statusCode}`));
} catch {
reject(new Error(`Agent returned ${res.statusCode}: ${data.substring(0, 500)}`));
}
return;
}
try {
resolve(data ? JSON.parse(data) as T : (undefined as T));
} catch {
resolve(data as unknown as T);
}
});
}
);
req.on('error', (err) => {
reject(new AgentUnreachableError(this.agentUrl, err));
});
req.on('timeout', () => {
req.destroy();
reject(new AgentUnreachableError(this.agentUrl, new Error(`Timed out after ${timeoutMs}ms`)));
});
if (payload) req.write(payload);
req.end();
});
}
// ─── Docker Compose Operations ──────────────────────────────
async composeUp(_projectDir: string, _project: string, services?: string[]): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/up`,
body: { services },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composeDown(_projectDir: string, _project: string, removeVolumes?: boolean): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/down`,
body: { removeVolumes },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composeStop(_projectDir: string, _project: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/stop`,
});
}
async composeRestart(_projectDir: string, _project: string, service?: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/restart`,
body: { service },
});
}
async composePull(_projectDir: string, _project: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/pull`,
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composeBuild(_projectDir: string, _project: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/build`,
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composePs(_projectDir: string, _project: string): Promise<ContainerInfo[]> {
return this.request<ContainerInfo[]>({
method: 'GET',
path: `/instance/${this.slug}/ps`,
});
}
async composeLogs(_projectDir: string, _project: string, service?: string, tail?: number, since?: string): Promise<string> {
const params = new URLSearchParams();
if (service) params.set('service', service);
if (tail) params.set('tail', String(tail));
if (since) params.set('since', since);
const qs = params.toString() ? `?${params}` : '';
return this.request<string>({
method: 'GET',
path: `/instance/${this.slug}/logs${qs}`,
});
}
async composeExec(_projectDir: string, _project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/exec`,
body: { service, command, envVars },
timeoutMs: timeoutMs || env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
// ─── Container Health ───────────────────────────────────────
async waitForHealthy(containerName: string, timeoutMs = 60_000, pollIntervalMs = 2_000): Promise<boolean> {
// For remote instances, poll the agent's ps endpoint
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const containers = await this.composePs('', '');
const container = containers.find((c) => c.name.includes(containerName) || c.service === containerName);
if (container?.health === 'healthy') return true;
if (container?.state === 'exited' || container?.state === 'dead') {
throw new Error(`Container ${containerName} exited unexpectedly`);
}
} catch (err) {
if (err instanceof AgentUnreachableError) throw err;
// Other errors — keep polling
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
}
async waitForHttp(url: string, timeoutMs = 120_000, pollIntervalMs = 3_000): Promise<boolean> {
// The URL is a local URL on the remote host. We ask the agent to check it.
// For now, poll the agent's health endpoint for the instance.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const containers = await this.composePs('', '');
const apiContainer = containers.find((c) => c.service === 'api');
if (apiContainer?.state === 'running' && apiContainer?.health === 'healthy') return true;
} catch (err) {
if (err instanceof AgentUnreachableError) throw err;
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`HTTP endpoint did not respond within ${timeoutMs}ms`);
}
// ─── Filesystem Operations ──────────────────────────────────
async readEnvFile(_basePath: string): Promise<Record<string, string> | null> {
try {
return await this.request<Record<string, string>>({
method: 'GET',
path: `/instance/${this.slug}/env`,
});
} catch {
return null;
}
}
async writeFiles(_basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void> {
await this.request({
method: 'POST',
path: `/instance/${this.slug}/files`,
body: { files },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async mkdir(_basePath: string, relativePath: string): Promise<void> {
await this.request({
method: 'POST',
path: `/instance/${this.slug}/mkdir`,
body: { path: relativePath },
});
}
async fileExists(_basePath: string, relativePath: string): Promise<boolean> {
try {
await this.request({
method: 'GET',
path: `/instance/${this.slug}/env`, // reuse env endpoint as a proxy for file existence
});
return true;
} catch {
return false;
}
}
async deleteDirectory(_dirPath: string): Promise<void> {
// Remote directory deletion is handled by the agent during instance unregistration
logger.warn('[remote-driver] deleteDirectory called — remote cleanup handled by agent');
}
async cloneSource(_basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise<void> {
await this.request({
method: 'POST',
path: `/instance/${this.slug}/clone-source`,
body: { gitRepo, gitBranch, excludes },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
}

View File

@@ -124,6 +124,7 @@ export interface InstanceForTemplate {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
jvbAdvertiseIp: string | null;
pangolinEndpoint: string | null;
pangolinNewtId: string | null;
@@ -293,3 +294,61 @@ export async function renderAllTemplates(context: TemplateContext, outputDir: st
export function clearTemplateCache(): void {
templateCache.clear();
}
/**
* Render all templates in memory and return them as an array of { relativePath, content }.
* Used for remote instances where we can't write to the local filesystem — rendered
* files are sent to the remote agent via HTTP instead.
*/
export async function renderAllTemplatesInMemory(
context: TemplateContext
): Promise<Array<{ relativePath: string; content: string }>> {
clearTemplateCache();
const templatesDir = path.resolve(__dirname, '../..', 'templates');
const result: Array<{ relativePath: string; content: string }> = [];
const templateFiles = [
{ template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' },
{ template: 'env.hbs', output: '.env' },
{ template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' },
{ template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' },
{ template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' },
{ template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' },
{ template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' },
{ template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' },
];
for (const { template, output } of templateFiles) {
const templatePath = path.join(templatesDir, template);
try {
await fs.access(templatePath);
} catch {
logger.warn(`Template not found: ${template}, skipping`);
continue;
}
const rendered = await renderTemplate(template, context);
result.push({ relativePath: output, content: rendered });
}
// Read static files into memory
const staticFiles = [
'nginx/nginx.conf',
'configs/prometheus/alerts.yml',
'configs/alertmanager/alertmanager.yml',
'configs/grafana/dashboards/dashboards.yml',
'configs/grafana/dashboards/application-overview.json',
'configs/grafana/dashboards/api-performance.json',
'configs/grafana/dashboards/system-health.json',
];
for (const file of staticFiles) {
const srcPath = path.join(templatesDir, file);
try {
const content = await fs.readFile(srcPath, 'utf-8');
result.push({ relativePath: file, content });
} catch {
logger.warn(`Static file not found: ${file}, skipping`);
}
}
return result;
}