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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "instances" ADD COLUMN "enable_analytics" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
130
changemaker-control-panel/api/src/services/local-driver.ts
Normal file
130
changemaker-control-panel/api/src/services/local-driver.ts
Normal 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;
|
||||
}
|
||||
264
changemaker-control-panel/api/src/services/remote-driver.ts
Normal file
264
changemaker-control-panel/api/src/services/remote-driver.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user