Merge changemaker-control-panel into v2 monorepo

Absorbs the separate control-panel git repo as a subdirectory.
Instances and backups directories excluded via .gitignore.

Bunker Admin
This commit is contained in:
2026-02-21 11:51:45 -07:00
parent 7352815e57
commit 2fa50b001c
80 changed files with 16513 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{
"name": "ccp-api",
"version": "1.0.0",
"description": "Changemaker Control Panel — API Server",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.3.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.5.0",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2",
"rate-limit-redis": "^4.2.0",
"winston": "^3.17.0",
"yaml": "^2.8.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"prisma": "^6.3.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,203 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "CcpRole" AS ENUM ('SUPER_ADMIN', 'OPERATOR', 'VIEWER');
-- CreateEnum
CREATE TYPE "InstanceStatus" AS ENUM ('PROVISIONING', 'RUNNING', 'STOPPED', 'ERROR', 'DESTROYING');
-- CreateEnum
CREATE TYPE "HealthStatus" AS ENUM ('HEALTHY', 'DEGRADED', 'UNHEALTHY', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "AuditAction" AS ENUM ('INSTANCE_CREATE', 'INSTANCE_UPDATE', 'INSTANCE_DELETE', 'INSTANCE_START', 'INSTANCE_STOP', 'INSTANCE_RESTART', 'INSTANCE_UPGRADE', 'BACKUP_CREATE', 'BACKUP_DELETE', 'PANGOLIN_SETUP', 'PANGOLIN_SYNC', 'USER_LOGIN', 'USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'SETTINGS_UPDATE');
-- CreateTable
CREATE TABLE "ccp_users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "CcpRole" NOT NULL DEFAULT 'OPERATOR',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ccp_users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ccp_refresh_tokens" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ccp_refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "instances" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"status" "InstanceStatus" NOT NULL DEFAULT 'PROVISIONING',
"status_message" TEXT,
"base_path" TEXT NOT NULL,
"compose_project" TEXT NOT NULL,
"git_branch" TEXT NOT NULL DEFAULT 'v2',
"git_commit" TEXT,
"port_config" JSONB NOT NULL,
"encrypted_secrets" TEXT NOT NULL,
"enable_media" BOOLEAN NOT NULL DEFAULT false,
"enable_chat" BOOLEAN NOT NULL DEFAULT false,
"enable_gancio" BOOLEAN NOT NULL DEFAULT false,
"enable_listmonk" BOOLEAN NOT NULL DEFAULT false,
"enable_monitoring" BOOLEAN NOT NULL DEFAULT false,
"admin_email" TEXT NOT NULL,
"pangolin_site_id" TEXT,
"pangolin_newt_id" TEXT,
"pangolin_newt_secret" TEXT,
"smtp_host" TEXT,
"smtp_port" INTEGER,
"smtp_user" TEXT,
"smtp_from" TEXT,
"email_test_mode" BOOLEAN NOT NULL DEFAULT true,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_health_check" TIMESTAMP(3),
CONSTRAINT "instances_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "port_allocations" (
"id" TEXT NOT NULL,
"port" INTEGER NOT NULL,
"instance_id" TEXT NOT NULL,
"service" TEXT NOT NULL,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "port_allocations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "health_checks" (
"id" TEXT NOT NULL,
"instance_id" TEXT NOT NULL,
"status" "HealthStatus" NOT NULL,
"service_status" JSONB NOT NULL,
"total_services" INTEGER NOT NULL,
"healthy_services" INTEGER NOT NULL,
"response_time_ms" INTEGER,
"checked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "health_checks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "backups" (
"id" TEXT NOT NULL,
"instance_id" TEXT NOT NULL,
"status" "BackupStatus" NOT NULL DEFAULT 'PENDING',
"archive_path" TEXT,
"size_bytes" BIGINT,
"manifest" JSONB,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"error_message" TEXT,
"s3_uploaded" BOOLEAN NOT NULL DEFAULT false,
"s3_key" TEXT,
CONSTRAINT "backups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"instance_id" TEXT,
"action" "AuditAction" NOT NULL,
"details" JSONB,
"ip_address" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ccp_settings" (
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ccp_settings_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE UNIQUE INDEX "ccp_users_email_key" ON "ccp_users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "ccp_refresh_tokens_token_key" ON "ccp_refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "ccp_refresh_tokens_user_id_idx" ON "ccp_refresh_tokens"("user_id");
-- CreateIndex
CREATE INDEX "ccp_refresh_tokens_expires_at_idx" ON "ccp_refresh_tokens"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "instances_slug_key" ON "instances"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "instances_domain_key" ON "instances"("domain");
-- CreateIndex
CREATE UNIQUE INDEX "instances_compose_project_key" ON "instances"("compose_project");
-- CreateIndex
CREATE UNIQUE INDEX "port_allocations_port_key" ON "port_allocations"("port");
-- CreateIndex
CREATE INDEX "port_allocations_instance_id_idx" ON "port_allocations"("instance_id");
-- CreateIndex
CREATE INDEX "health_checks_instance_id_checked_at_idx" ON "health_checks"("instance_id", "checked_at");
-- CreateIndex
CREATE INDEX "backups_instance_id_started_at_idx" ON "backups"("instance_id", "started_at");
-- CreateIndex
CREATE INDEX "audit_logs_instance_id_created_at_idx" ON "audit_logs"("instance_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_logs_user_id_created_at_idx" ON "audit_logs"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_logs_action_created_at_idx" ON "audit_logs"("action", "created_at");
-- AddForeignKey
ALTER TABLE "ccp_refresh_tokens" ADD CONSTRAINT "ccp_refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ccp_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "port_allocations" ADD CONSTRAINT "port_allocations_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "health_checks" ADD CONSTRAINT "health_checks_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "backups" ADD CONSTRAINT "backups_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ccp_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "is_registered" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "encrypted_secrets" DROP NOT NULL;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "enable_dev_tools" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "enable_payments" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,232 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── CCP Users (control panel operators) ───────────────────
enum CcpRole {
SUPER_ADMIN
OPERATOR
VIEWER
}
model CcpUser {
id String @id @default(uuid())
email String @unique
password String
name String
role CcpRole @default(OPERATOR)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
refreshTokens CcpRefreshToken[]
auditLogs AuditLog[]
@@map("ccp_users")
}
model CcpRefreshToken {
id String @id @default(uuid())
token String @unique
userId String @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user CcpUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("ccp_refresh_tokens")
}
// ─── Managed Instances ─────────────────────────────────────
enum InstanceStatus {
PROVISIONING
RUNNING
STOPPED
ERROR
DESTROYING
}
model Instance {
id String @id @default(uuid())
slug String @unique
name String
domain String @unique
status InstanceStatus @default(PROVISIONING)
statusMessage String? @map("status_message")
basePath String @map("base_path")
composeProject String @unique @map("compose_project")
gitBranch String @default("v2") @map("git_branch")
gitCommit String? @map("git_commit")
// Allocated host ports (JSON: { api: 14001, admin: 13001, postgres: 15401, nginx: 10001 })
portConfig Json @map("port_config")
// AES-256-GCM encrypted JSON blob of all instance secrets (null for registered instances)
encryptedSecrets String? @map("encrypted_secrets")
// True if this instance was registered externally (not provisioned by CCP)
isRegistered Boolean @default(false) @map("is_registered")
// Feature flags
enableMedia Boolean @default(false) @map("enable_media")
enableChat Boolean @default(false) @map("enable_chat")
enableGancio Boolean @default(false) @map("enable_gancio")
enableListmonk Boolean @default(false) @map("enable_listmonk")
enableMonitoring Boolean @default(false) @map("enable_monitoring")
enableDevTools Boolean @default(false) @map("enable_dev_tools")
enablePayments Boolean @default(false) @map("enable_payments")
// Admin config
adminEmail String @map("admin_email")
// Pangolin tunnel
pangolinSiteId String? @map("pangolin_site_id")
pangolinNewtId String? @map("pangolin_newt_id")
pangolinNewtSecret String? @map("pangolin_newt_secret")
// SMTP
smtpHost String? @map("smtp_host")
smtpPort Int? @map("smtp_port")
smtpUser String? @map("smtp_user")
smtpFrom String? @map("smtp_from")
emailTestMode Boolean @default(true) @map("email_test_mode")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastHealthCheck DateTime? @map("last_health_check")
portAllocations PortAllocation[]
healthChecks HealthCheck[]
backups Backup[]
auditLogs AuditLog[]
@@map("instances")
}
// ─── Port Allocation ───────────────────────────────────────
model PortAllocation {
id String @id @default(uuid())
port Int @unique
instanceId String @map("instance_id")
service String
notes String?
createdAt DateTime @default(now()) @map("created_at")
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
@@index([instanceId])
@@map("port_allocations")
}
// ─── Health Checks ─────────────────────────────────────────
enum HealthStatus {
HEALTHY
DEGRADED
UNHEALTHY
UNKNOWN
}
model HealthCheck {
id String @id @default(uuid())
instanceId String @map("instance_id")
status HealthStatus
serviceStatus Json @map("service_status")
totalServices Int @map("total_services")
healthyServices Int @map("healthy_services")
responseTimeMs Int? @map("response_time_ms")
checkedAt DateTime @default(now()) @map("checked_at")
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
@@index([instanceId, checkedAt])
@@map("health_checks")
}
// ─── Backups ───────────────────────────────────────────────
enum BackupStatus {
PENDING
IN_PROGRESS
COMPLETED
FAILED
}
model Backup {
id String @id @default(uuid())
instanceId String @map("instance_id")
status BackupStatus @default(PENDING)
archivePath String? @map("archive_path")
sizeBytes BigInt? @map("size_bytes")
manifest Json?
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
errorMessage String? @map("error_message")
s3Uploaded Boolean @default(false) @map("s3_uploaded")
s3Key String? @map("s3_key")
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
@@index([instanceId, startedAt])
@@map("backups")
}
// ─── Audit Log ─────────────────────────────────────────────
enum AuditAction {
INSTANCE_CREATE
INSTANCE_UPDATE
INSTANCE_DELETE
INSTANCE_START
INSTANCE_STOP
INSTANCE_RESTART
INSTANCE_UPGRADE
BACKUP_CREATE
BACKUP_DELETE
PANGOLIN_SETUP
PANGOLIN_SYNC
USER_LOGIN
USER_CREATE
USER_UPDATE
USER_DELETE
SETTINGS_UPDATE
}
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id")
instanceId String? @map("instance_id")
action AuditAction
details Json?
ipAddress String? @map("ip_address")
createdAt DateTime @default(now()) @map("created_at")
user CcpUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: SetNull)
@@index([instanceId, createdAt])
@@index([userId, createdAt])
@@index([action, createdAt])
@@map("audit_logs")
}
// ─── CCP Settings ──────────────────────────────────────────
model CcpSetting {
key String @id
value Json
updatedAt DateTime @updatedAt @map("updated_at")
@@map("ccp_settings")
}

View File

@@ -0,0 +1,49 @@
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
const email = process.env.INITIAL_ADMIN_EMAIL || 'admin@example.com';
const password = process.env.INITIAL_ADMIN_PASSWORD || 'ChangeMe2025!!';
// Create initial admin user
const existing = await prisma.ccpUser.findUnique({ where: { email } });
if (!existing) {
const hashedPassword = await bcrypt.hash(password, 12);
await prisma.ccpUser.create({
data: {
email,
password: hashedPassword,
name: 'Admin',
role: 'SUPER_ADMIN',
},
});
console.log(`Created initial admin user: ${email}`);
} else {
console.log(`Admin user already exists: ${email}`);
}
// Create default settings
const defaults: Record<string, unknown> = {
defaultGitBranch: 'v2',
instancesBasePath: process.env.INSTANCES_BASE_PATH || 'instances',
};
for (const [key, value] of Object.entries(defaults)) {
await prisma.ccpSetting.upsert({
where: { key },
update: {},
create: { key, value: value as string },
});
}
console.log('Default settings seeded');
}
main()
.catch((e) => {
console.error('Seed error:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,80 @@
import 'dotenv/config';
import path from 'path';
import { z } from 'zod';
const envSchema = z.object({
// Server
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(5000),
// Database
DATABASE_URL: z.string().url(),
// Redis
REDIS_URL: z.string().default('redis://localhost:6399'),
// JWT
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
// Encryption key for secrets at rest (64 hex chars = 32 bytes for AES-256)
ENCRYPTION_KEY: z.string().min(64).regex(/^[0-9a-f]+$/i, 'Must be hex-encoded (use: openssl rand -hex 32)'),
// Initial admin
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@example.com'),
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('ChangeMe2025!!'),
// CORS
CORS_ORIGINS: z.string().default('http://localhost:5100'),
// Instance management (resolved by setup.sh; fallback for local dev)
INSTANCES_BASE_PATH: z.string().default(
path.resolve(process.cwd(), '..', 'instances')
),
CML_SOURCE_PATH: z.string().default(''),
CML_GIT_REPO: z.string().default(''),
CML_GIT_BRANCH: z.string().default('v2'),
// Port allocation ranges
PORT_RANGE_API_START: z.coerce.number().default(14000),
PORT_RANGE_API_END: z.coerce.number().default(14999),
PORT_RANGE_ADMIN_START: z.coerce.number().default(13000),
PORT_RANGE_ADMIN_END: z.coerce.number().default(13999),
PORT_RANGE_POSTGRES_START: z.coerce.number().default(15400),
PORT_RANGE_POSTGRES_END: z.coerce.number().default(15499),
PORT_RANGE_NGINX_START: z.coerce.number().default(10000),
PORT_RANGE_NGINX_END: z.coerce.number().default(10999),
PORT_RANGE_EMBED_START: z.coerce.number().default(12000),
PORT_RANGE_EMBED_END: z.coerce.number().default(12499),
// Pangolin (optional)
PANGOLIN_API_URL: z.string().default(''),
PANGOLIN_API_KEY: z.string().default(''),
PANGOLIN_ORG_ID: z.string().default(''),
// Health checks
HEALTH_CHECK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 min (0 to disable)
// Backups
BACKUP_STORAGE_PATH: z.string().default(
path.resolve(process.cwd(), '..', 'backups')
),
BACKUP_RETENTION_DAYS: z.coerce.number().default(30),
});
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:');
for (const [key, errors] of Object.entries(result.error.flatten().fieldErrors)) {
console.error(` ${key}: ${errors?.join(', ')}`);
}
process.exit(1);
}
return result.data;
}
export const env = validateEnv();
export type Env = z.infer<typeof envSchema>;

View File

@@ -0,0 +1,14 @@
import Redis from 'ioredis';
import { env } from './env';
import { logger } from '../utils/logger';
export const redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
if (times > 10) return null;
return Math.min(times * 200, 5000);
},
});
redis.on('connect', () => logger.info('Redis connected'));
redis.on('error', (err) => logger.error('Redis error:', err.message));

View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

View File

@@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { CcpRole } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './error-handler';
interface TokenPayload {
id: string;
email: string;
role: CcpRole;
}
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
export function authenticate(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
req.user = {
id: payload.id,
email: payload.email,
role: payload.role,
};
next();
} catch {
throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN');
}
}
export function requireRole(...roles: CcpRole[]) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
if (!roles.includes(req.user.role)) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
next();
};
}

View File

@@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { env } from '../config/env';
import { logger } from '../utils/logger';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: { message: err.message, code: err.code },
});
return;
}
if (err instanceof ZodError) {
const fieldErrors = err.flatten().fieldErrors;
const errorCount = Object.keys(fieldErrors).length;
res.status(400).json({
error: {
message: 'Validation error',
code: 'VALIDATION_ERROR',
...(env.NODE_ENV === 'development' && { details: fieldErrors }),
...(env.NODE_ENV === 'production' && { fieldCount: errorCount }),
},
});
return;
}
logger.error('Unhandled error:', err);
res.status(500).json({
error: {
message: env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
},
});
}

View File

@@ -0,0 +1,16 @@
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, _res: Response, next: NextFunction) => {
schema.parse(req.body);
next();
};
}
export function validateQuery(schema: ZodSchema) {
return (req: Request, _res: Response, next: NextFunction) => {
schema.parse(req.query);
next();
};
}

View File

@@ -0,0 +1,30 @@
import { Router, Request, Response } from 'express';
import { AuditAction } from '@prisma/client';
import { authenticate, requireRole } from '../../middleware/auth';
import * as auditService from './audit.service';
const router = Router();
router.use(authenticate);
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const { action, instanceId, userId, from, to, page, limit } = req.query;
const filters = {
action: action && Object.values(AuditAction).includes(action as AuditAction)
? (action as AuditAction)
: undefined,
instanceId: instanceId as string | undefined,
userId: userId as string | undefined,
from: from as string | undefined,
to: to as string | undefined,
};
const pageNum = Math.max(1, parseInt(page as string, 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 50));
const result = await auditService.listAuditLogs(filters, pageNum, limitNum);
res.json(result);
});
export default router;

View File

@@ -0,0 +1,43 @@
import { AuditAction, Prisma } from '@prisma/client';
import { prisma } from '../../lib/prisma';
interface AuditFilters {
action?: AuditAction;
instanceId?: string;
userId?: string;
from?: string;
to?: string;
}
export async function listAuditLogs(
filters: AuditFilters,
page = 1,
limit = 50
) {
const where: Prisma.AuditLogWhereInput = {};
if (filters.action) where.action = filters.action;
if (filters.instanceId) where.instanceId = filters.instanceId;
if (filters.userId) where.userId = filters.userId;
if (filters.from || filters.to) {
where.createdAt = {};
if (filters.from) where.createdAt.gte = new Date(filters.from);
if (filters.to) where.createdAt.lte = new Date(filters.to);
}
const [data, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
include: {
user: { select: { id: true, email: true, name: true } },
instance: { select: { id: true, name: true, slug: true } },
},
}),
prisma.auditLog.count({ where }),
]);
return { data, total, page, limit };
}

View File

@@ -0,0 +1,59 @@
import { Router, Request, Response } from 'express';
import { AuditAction } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { authenticate } from '../../middleware/auth';
import { validate } from '../../middleware/validate';
import { loginSchema, refreshSchema, logoutSchema, verifyPasswordSchema } from './auth.schemas';
import * as authService from './auth.service';
const router = Router();
router.post('/login', validate(loginSchema), async (req: Request, res: Response) => {
const { email, password } = req.body;
const result = await authService.login(email, password);
// Audit log the login
await prisma.auditLog.create({
data: {
userId: result.user.id,
action: AuditAction.USER_LOGIN,
details: { email: result.user.email },
ipAddress: req.ip,
},
});
res.json(result);
});
router.post('/refresh', validate(refreshSchema), async (req: Request, res: Response) => {
const { refreshToken } = req.body;
const result = await authService.refresh(refreshToken);
res.json(result);
});
router.post('/logout', validate(logoutSchema), async (req: Request, res: Response) => {
const { refreshToken } = req.body;
await authService.logout(refreshToken);
res.json({ message: 'Logged out' });
});
router.post(
'/verify-password',
authenticate,
validate(verifyPasswordSchema),
async (req: Request, res: Response) => {
const { password } = req.body;
const valid = await authService.verifyPassword(req.user!.id, password);
if (!valid) {
res.status(401).json({ error: { message: 'Invalid password', code: 'INVALID_PASSWORD' } });
return;
}
res.json({ verified: true });
}
);
router.get('/me', authenticate, async (req: Request, res: Response) => {
const user = await authService.getMe(req.user!.id);
res.json({ user });
});
export default router;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const refreshSchema = z.object({
refreshToken: z.string().min(1),
});
export const logoutSchema = z.object({
refreshToken: z.string().min(1),
});
export const verifyPasswordSchema = z.object({
password: z.string().min(1),
});

View File

@@ -0,0 +1,131 @@
import bcrypt from 'bcryptjs';
import jwt, { SignOptions } from 'jsonwebtoken';
import crypto from 'crypto';
import { CcpRole } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
interface TokenPayload {
id: string;
email: string;
role: CcpRole;
}
function signAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_EXPIRES_IN as SignOptions['expiresIn'],
});
}
function signRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
});
}
function parseExpiry(expiresIn: string): Date {
const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // default 7d
const [, num, unit] = match;
const multipliers: Record<string, number> = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
return new Date(Date.now() + parseInt(num) * multipliers[unit]);
}
export async function login(email: string, password: string) {
const user = await prisma.ccpUser.findUnique({ where: { email } });
if (!user) {
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
}
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
// Store refresh token
await prisma.ccpRefreshToken.create({
data: {
token: crypto.createHash('sha256').update(refreshToken).digest('hex'),
userId: user.id,
expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN),
},
});
return {
user: { id: user.id, email: user.email, name: user.name, role: user.role },
accessToken,
refreshToken,
};
}
export async function refresh(refreshToken: string) {
let payload: TokenPayload;
try {
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload;
} catch {
throw new AppError(401, 'Invalid refresh token', 'INVALID_TOKEN');
}
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
// Atomic rotation: delete old, create new
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.ccpRefreshToken.findUnique({ where: { token: tokenHash } });
if (!existing || existing.expiresAt < new Date()) {
throw new AppError(401, 'Refresh token expired or revoked', 'TOKEN_EXPIRED');
}
await tx.ccpRefreshToken.delete({ where: { token: tokenHash } });
const user = await tx.ccpUser.findUnique({ where: { id: payload.id } });
if (!user) {
throw new AppError(401, 'User not found', 'USER_NOT_FOUND');
}
const newPayload: TokenPayload = { id: user.id, email: user.email, role: user.role };
const newAccessToken = signAccessToken(newPayload);
const newRefreshToken = signRefreshToken(newPayload);
await tx.ccpRefreshToken.create({
data: {
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
userId: user.id,
expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN),
},
});
return {
user: { id: user.id, email: user.email, name: user.name, role: user.role },
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
});
return result;
}
export async function logout(refreshToken: string) {
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await prisma.ccpRefreshToken.deleteMany({ where: { token: tokenHash } });
}
export async function verifyPassword(userId: string, password: string): Promise<boolean> {
const user = await prisma.ccpUser.findUnique({ where: { id: userId } });
if (!user) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
return bcrypt.compare(password, user.password);
}
export async function getMe(userId: string) {
const user = await prisma.ccpUser.findUnique({ where: { id: userId } });
if (!user) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
return { id: user.id, email: user.email, name: user.name, role: user.role };
}

View File

@@ -0,0 +1,71 @@
import { Router, Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { authenticate, requireRole } from '../../middleware/auth';
import { env } from '../../config/env';
import * as backupService from '../../services/backup.service';
const router = Router();
router.use(authenticate);
// ─── Cross-Instance Backup Endpoints ────────────────────────────────
// List all backups (cross-instance)
router.get('/', async (req: Request, res: Response) => {
const { instanceId, page, limit } = req.query;
const pageNum = Math.max(1, parseInt(page as string, 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 50));
const result = await backupService.listBackups(
instanceId as string | undefined,
pageNum,
limitNum
);
res.json(result);
});
// Delete a backup
router.delete(
'/:backupId',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
await backupService.deleteBackup(req.params.backupId as string, req.user!.id, req.ip);
res.json({ message: 'Backup deleted' });
}
);
// Download a backup archive
router.get(
'/:backupId/download',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const backup = await backupService.getBackup(req.params.backupId as string);
if (!backup.archivePath || backup.status !== 'COMPLETED') {
res.status(400).json({ error: { message: 'Backup not available for download', code: 'NOT_AVAILABLE' } });
return;
}
// Validate path is within backup storage (prevent traversal)
const normalized = path.resolve(backup.archivePath);
const normalizedStorage = path.resolve(env.BACKUP_STORAGE_PATH);
if (!normalized.startsWith(normalizedStorage + path.sep)) {
res.status(403).json({ error: { message: 'Access denied', code: 'FORBIDDEN' } });
return;
}
if (!fs.existsSync(normalized)) {
res.status(404).json({ error: { message: 'Backup file not found', code: 'FILE_NOT_FOUND' } });
return;
}
const filename = path.basename(normalized);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/gzip');
const stream = fs.createReadStream(normalized);
stream.pipe(res);
}
);
export default router;

View File

@@ -0,0 +1,46 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../../lib/prisma';
import { authenticate } from '../../middleware/auth';
const router = Router();
// Public health endpoint for CCP itself
router.get('/', async (_req: Request, res: Response) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
} catch {
res.status(503).json({ status: 'unhealthy', timestamp: new Date().toISOString() });
}
});
// Authenticated: overview of all instances' health
router.get('/overview', authenticate, async (_req: Request, res: Response) => {
const instances = await prisma.instance.findMany({
select: {
id: true,
name: true,
slug: true,
domain: true,
status: true,
lastHealthCheck: true,
healthChecks: {
orderBy: { checkedAt: 'desc' },
take: 1,
},
},
});
const summary = instances.map((i) => ({
id: i.id,
name: i.name,
slug: i.slug,
domain: i.domain,
status: i.status,
lastHealthCheck: i.lastHealthCheck,
health: i.healthChecks[0] || null,
}));
res.json({ data: summary });
});
export default router;

View File

@@ -0,0 +1,278 @@
import { Router, Request, Response } from 'express';
import { AuditAction } from '@prisma/client';
import rateLimit from 'express-rate-limit';
import { prisma } from '../../lib/prisma';
import { authenticate, requireRole } from '../../middleware/auth';
import { validate } from '../../middleware/validate';
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, importInstancesSchema } from './instances.schemas';
import * as instancesService from './instances.service';
import * as healthService from '../../services/health.service';
import * as backupService from '../../services/backup.service';
import { discoverInstances } from '../../services/discovery.service';
const secretsLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: { message: 'Too many secrets requests, please try again later', code: 'RATE_LIMITED' } },
});
const router = Router();
// All instance routes require authentication
router.use(authenticate);
// ─── Discovery Endpoints ────────────────────────────────────────────
router.post(
'/discover',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (_req: Request, res: Response) => {
const result = await discoverInstances();
res.json({ data: result });
}
);
router.post(
'/import',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(importInstancesSchema),
async (req: Request, res: Response) => {
const { instances } = req.body as { instances: Array<Record<string, unknown>> };
const results: Array<{ slug: string; success: boolean; instanceId?: string; error?: string }> = [];
for (const inst of instances) {
try {
const registered = await instancesService.registerInstance(
inst as Parameters<typeof instancesService.registerInstance>[0],
req.user!.id,
req.ip
);
results.push({ slug: inst.slug as string, success: true, instanceId: registered?.id });
} catch (err) {
results.push({ slug: inst.slug as string, success: false, error: (err as Error).message });
}
}
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
res.json({
data: {
results,
summary: { total: results.length, succeeded, failed },
},
});
}
);
// ─── CRUD Endpoints ──────────────────────────────────────────────────
router.get('/', async (_req: Request, res: Response) => {
const instances = await instancesService.listInstances();
res.json({ data: instances });
});
// Register an existing (externally-managed) instance for monitoring
router.post(
'/register',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(registerInstanceSchema),
async (req: Request, res: Response) => {
const instance = await instancesService.registerInstance(req.body, req.user!.id, req.ip);
res.status(201).json({ data: instance });
}
);
router.get('/:id', async (req: Request, res: Response) => {
const instance = await instancesService.getInstance(req.params.id as string);
res.json({ data: instance });
});
router.post(
'/',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(createInstanceSchema),
async (req: Request, res: Response) => {
const instance = await instancesService.createInstance(req.body, req.user!.id, req.ip);
res.status(201).json({ data: instance });
}
);
router.put(
'/:id',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(updateInstanceSchema),
async (req: Request, res: Response) => {
const instance = await instancesService.updateInstance(
req.params.id as string,
req.body,
req.user!.id,
req.ip
);
res.json({ data: instance });
}
);
router.delete(
'/:id',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const result = await instancesService.deleteInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
// Get decrypted secrets (SUPER_ADMIN only, rate limited)
router.get(
'/:id/secrets',
secretsLimiter,
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const secrets = await instancesService.getInstanceSecrets(req.params.id as string);
// Audit log: someone viewed secrets
await prisma.auditLog.create({
data: {
userId: req.user!.id,
instanceId: req.params.id as string,
action: AuditAction.INSTANCE_UPDATE,
details: { type: 'secrets_viewed' },
ipAddress: req.ip,
},
});
res.json({ data: secrets });
}
);
// ─── Reconfiguration ────────────────────────────────────────────────
router.post(
'/:id/reconfigure',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(reconfigureInstanceSchema),
async (req: Request, res: Response) => {
const result = await instancesService.reconfigureInstance(
req.params.id as string,
req.body,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
router.post(
'/:id/provision',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const result = await instancesService.provisionInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
router.post(
'/:id/start',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const result = await instancesService.startInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
router.post(
'/:id/stop',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const result = await instancesService.stopInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
router.post(
'/:id/restart',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const service = req.query.service as string | undefined;
const result = await instancesService.restartInstance(
req.params.id as string,
req.user!.id,
req.ip,
service
);
res.json(result);
}
);
// ─── Services & Logs ─────────────────────────────────────────────────
router.get(
'/:id/services',
async (req: Request, res: Response) => {
const services = await instancesService.getInstanceServices(req.params.id as string);
res.json({ data: services });
}
);
router.get(
'/:id/logs',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const { service, tail, since } = req.query;
const tailNum = tail ? Math.min(Math.max(parseInt(tail as string, 10) || 200, 1), 2000) : 200;
const logs = await instancesService.getInstanceLogs(
req.params.id as string,
service as string | undefined,
tailNum,
since as string | undefined
);
res.json({ data: logs });
}
);
// ─── Health Checks ──────────────────────────────────────────────────
router.post(
'/:id/health-check',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const check = await healthService.checkInstanceHealth(req.params.id as string);
res.json({ data: check });
}
);
router.get(
'/:id/health-history',
async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
const result = await healthService.getHealthHistory(req.params.id as string, page, limit);
res.json(result);
}
);
// ─── Backups ────────────────────────────────────────────────────────
router.post(
'/:id/backup',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const backup = await backupService.createBackup(req.params.id as string, req.user!.id, req.ip);
res.status(201).json({ data: backup });
}
);
router.get(
'/:id/backups',
async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50));
const result = await backupService.listBackups(req.params.id as string, page, limit);
res.json(result);
}
);
export default router;

View File

@@ -0,0 +1,82 @@
import { z } from 'zod';
export const createInstanceSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
domain: z.string().min(3).max(255),
adminEmail: z.string().email(),
enableMedia: z.boolean().default(false),
enableChat: z.boolean().default(false),
enableGancio: z.boolean().default(false),
enableListmonk: z.boolean().default(false),
enableMonitoring: z.boolean().default(false),
enableDevTools: z.boolean().default(false),
enablePayments: z.boolean().default(false),
smtpHost: z.string().optional(),
smtpPort: z.coerce.number().optional(),
smtpUser: z.string().optional(),
smtpFrom: z.string().optional(),
emailTestMode: z.boolean().default(true),
enablePangolin: z.boolean().default(false),
notes: z.string().optional(),
});
export const updateInstanceSchema = z.object({
name: z.string().min(2).max(100).optional(),
enableMedia: z.boolean().optional(),
enableChat: z.boolean().optional(),
enableGancio: z.boolean().optional(),
enableListmonk: z.boolean().optional(),
enableMonitoring: z.boolean().optional(),
enableDevTools: z.boolean().optional(),
enablePayments: z.boolean().optional(),
smtpHost: z.string().optional(),
smtpPort: z.coerce.number().optional(),
smtpUser: z.string().optional(),
smtpFrom: z.string().optional(),
emailTestMode: z.boolean().optional(),
notes: z.string().nullable().optional(),
});
export const registerInstanceSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
domain: z.string().min(3).max(255),
basePath: z.string().min(1),
composeProject: z.string().min(1),
portConfig: z.object({
api: z.coerce.number().int().min(1).max(65535),
admin: z.coerce.number().int().min(1).max(65535),
postgres: z.coerce.number().int().min(1).max(65535),
nginx: z.coerce.number().int().min(1).max(65535),
}),
adminEmail: z.string().email().optional().default('admin@localhost'),
enableMedia: z.boolean().default(false),
enableChat: z.boolean().default(false),
enableGancio: z.boolean().default(false),
enableListmonk: z.boolean().default(false),
enableMonitoring: z.boolean().default(false),
enableDevTools: z.boolean().default(false),
enablePayments: z.boolean().default(false),
notes: z.string().optional(),
});
export const reconfigureInstanceSchema = z.object({
enableMedia: z.boolean().optional(),
enableChat: z.boolean().optional(),
enableGancio: z.boolean().optional(),
enableListmonk: z.boolean().optional(),
enableMonitoring: z.boolean().optional(),
enableDevTools: z.boolean().optional(),
enablePayments: z.boolean().optional(),
});
export const importInstancesSchema = z.object({
instances: z.array(registerInstanceSchema).min(1).max(50),
});
export type CreateInstanceInput = z.infer<typeof createInstanceSchema>;
export type UpdateInstanceInput = z.infer<typeof updateInstanceSchema>;
export type RegisterInstanceInput = z.infer<typeof registerInstanceSchema>;
export type ReconfigureInstanceInput = z.infer<typeof reconfigureInstanceSchema>;
export type ImportInstancesInput = z.infer<typeof importInstancesSchema>;

View File

@@ -0,0 +1,607 @@
import { Prisma, InstanceStatus, AuditAction } from '@prisma/client';
import fs from 'fs/promises';
import { parse as parseDotenv } from 'dotenv';
import { prisma } from '../../lib/prisma';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
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 { provision } from './provisioner';
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput } from './instances.schemas';
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
import { logger } from '../../utils/logger';
import path from 'path';
// ─── CRUD Operations ─────────────────────────────────────────────────
export async function listInstances() {
return prisma.instance.findMany({
orderBy: { createdAt: 'desc' },
omit: { encryptedSecrets: true },
include: {
portAllocations: true,
_count: { select: { healthChecks: true, backups: true } },
},
});
}
export async function getInstance(id: string) {
const instance = await prisma.instance.findUnique({
where: { id },
omit: { encryptedSecrets: true },
include: {
portAllocations: true,
healthChecks: { orderBy: { checkedAt: 'desc' }, take: 10 },
backups: { orderBy: { startedAt: 'desc' }, take: 10 },
},
});
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
return instance;
}
export async function createInstance(input: CreateInstanceInput, userId: string, ipAddress?: string) {
// Check uniqueness
const existing = await prisma.instance.findFirst({
where: { OR: [{ slug: input.slug }, { domain: input.domain }] },
});
if (existing) {
const field = existing.slug === input.slug ? 'slug' : 'domain';
throw new AppError(409, `Instance with this ${field} already exists`, 'DUPLICATE');
}
// Allocate ports
const ports = await allocatePorts();
// Generate secrets
const secrets = generateSecrets(input.adminEmail);
// Compute paths
const composeProject = `cml-${input.slug}`;
const basePath = path.join(env.INSTANCES_BASE_PATH, input.slug, 'changemaker.lite');
// Create instance record
const instance = await prisma.instance.create({
data: {
slug: input.slug,
name: input.name,
domain: input.domain,
status: InstanceStatus.PROVISIONING,
basePath,
composeProject,
gitBranch: env.CML_GIT_BRANCH,
portConfig: ports.config,
encryptedSecrets: encryptJson(secrets as unknown as Record<string, unknown>),
enableMedia: input.enableMedia,
enableChat: input.enableChat,
enableGancio: input.enableGancio,
enableListmonk: input.enableListmonk,
enableMonitoring: input.enableMonitoring,
enableDevTools: input.enableDevTools,
enablePayments: input.enablePayments,
adminEmail: input.adminEmail,
smtpHost: input.smtpHost,
smtpPort: input.smtpPort,
smtpUser: input.smtpUser,
smtpFrom: input.smtpFrom,
emailTestMode: input.emailTestMode,
notes: input.notes,
portAllocations: {
create: ports.allocations,
},
},
include: { portAllocations: true },
});
// Audit log
await prisma.auditLog.create({
data: {
userId,
instanceId: instance.id,
action: AuditAction.INSTANCE_CREATE,
details: { name: input.name, domain: input.domain, slug: input.slug },
ipAddress,
},
});
// Kick off provisioning asynchronously (fire-and-forget)
provision(instance.id).catch((err) => {
logger.error(`[instances] Provisioning failed for ${instance.slug}: ${err}`);
});
return instance;
}
export async function registerInstance(input: RegisterInstanceInput, userId: string, ipAddress?: string) {
// Check uniqueness (slug, domain, composeProject)
const existing = await prisma.instance.findFirst({
where: {
OR: [
{ slug: input.slug },
{ domain: input.domain },
{ composeProject: input.composeProject },
],
},
});
if (existing) {
const field = existing.slug === input.slug ? 'slug'
: existing.domain === input.domain ? 'domain'
: 'composeProject';
throw new AppError(409, `Instance with this ${field} already exists`, 'DUPLICATE');
}
// Verify basePath has a docker-compose.yml
try {
await fs.access(path.join(input.basePath, 'docker-compose.yml'));
} catch {
throw new AppError(400, `No docker-compose.yml found at ${input.basePath}`, 'INVALID_PATH');
}
// Detect running containers to determine initial status
let initialStatus: InstanceStatus = InstanceStatus.STOPPED;
try {
const containers = await docker.composePs(input.basePath, input.composeProject);
const runningCount = containers.filter((c) => c.state === 'running').length;
if (runningCount > 0) {
initialStatus = InstanceStatus.RUNNING;
}
} catch {
logger.warn(`[instances] Could not detect containers for ${input.composeProject}, defaulting to STOPPED`);
}
// Create instance record
const instance = await prisma.instance.create({
data: {
slug: input.slug,
name: input.name,
domain: input.domain,
status: initialStatus,
statusMessage: initialStatus === InstanceStatus.RUNNING ? 'Registered — containers running' : 'Registered — containers not running',
basePath: input.basePath,
composeProject: input.composeProject,
portConfig: input.portConfig,
encryptedSecrets: null,
isRegistered: true,
enableMedia: input.enableMedia,
enableChat: input.enableChat,
enableGancio: input.enableGancio,
enableListmonk: input.enableListmonk,
enableMonitoring: input.enableMonitoring,
enableDevTools: input.enableDevTools,
enablePayments: input.enablePayments,
adminEmail: input.adminEmail,
notes: input.notes,
},
});
// Create PortAllocation records (try-catch each for unique constraint)
for (const [service, port] of Object.entries(input.portConfig)) {
try {
await prisma.portAllocation.create({
data: { port, service, instanceId: instance.id },
});
} catch (err) {
logger.warn(`[instances] Port ${port} (${service}) already allocated, skipping`);
}
}
// Audit log
await prisma.auditLog.create({
data: {
userId,
instanceId: instance.id,
action: AuditAction.INSTANCE_CREATE,
details: { name: input.name, domain: input.domain, slug: input.slug, registered: true },
ipAddress,
},
});
// Trigger immediate health check if running
if (initialStatus === InstanceStatus.RUNNING) {
import('../../services/health.service').then((healthService) => {
healthService.checkInstanceHealth(instance.id).catch((err) => {
logger.warn(`[instances] Initial health check failed for ${instance.slug}: ${(err as Error).message}`);
});
});
}
// Re-fetch with relations
return prisma.instance.findUnique({
where: { id: instance.id },
include: { portAllocations: true },
});
}
export async function updateInstance(id: string, input: UpdateInstanceInput, userId: string, ipAddress?: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
const updated = await prisma.instance.update({
where: { id },
data: input,
});
await prisma.auditLog.create({
data: {
userId,
instanceId: id,
action: AuditAction.INSTANCE_UPDATE,
details: input as unknown as Prisma.InputJsonValue,
ipAddress,
},
});
return updated;
}
export async function deleteInstance(id: string, userId: string, ipAddress?: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
// Registered instances: just remove from DB, never touch containers or files
if (instance.isRegistered) {
await releasePorts(id);
await prisma.instance.delete({ where: { id } });
await prisma.auditLog.create({
data: {
userId,
action: AuditAction.INSTANCE_DELETE,
details: { name: instance.name, domain: instance.domain, slug: instance.slug, unregistered: true },
ipAddress,
},
});
return { message: 'Instance unregistered' };
}
// Mark as destroying
await prisma.instance.update({
where: { id },
data: { status: InstanceStatus.DESTROYING, statusMessage: 'Shutting down containers...' },
});
// Stop containers and remove volumes
try {
await docker.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}`);
// Continue with deletion even if docker cleanup partially fails
}
// Delete instance directory (with safety check)
const instanceDir = path.resolve(path.dirname(instance.basePath));
const expectedBase = path.resolve(env.INSTANCES_BASE_PATH);
if (instanceDir.startsWith(expectedBase + '/') && instanceDir !== expectedBase) {
try {
await fs.rm(instanceDir, { recursive: true, force: true });
logger.info(`[instances] ${instance.slug}: Directory ${instanceDir} removed`);
} catch (err) {
logger.warn(`[instances] ${instance.slug}: Directory cleanup warning: ${(err as Error).message}`);
}
} else {
logger.error(`[instances] ${instance.slug}: Refusing to delete path outside base: ${instanceDir}`);
}
// Release ports and delete instance from DB
await releasePorts(id);
await prisma.instance.delete({ where: { id } });
await prisma.auditLog.create({
data: {
userId,
action: AuditAction.INSTANCE_DELETE,
details: { name: instance.name, domain: instance.domain, slug: instance.slug },
ipAddress,
},
});
return { message: 'Instance deleted' };
}
export async function getInstanceSecrets(id: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
// CCP-provisioned instance: decrypt from DB
if (!instance.isRegistered && instance.encryptedSecrets) {
return decryptJson(instance.encryptedSecrets);
}
// Registered/discovered instance: read from .env on disk
// Path traversal protection: basePath must be within INSTANCES_BASE_PATH or CML_SOURCE_PATH
const resolvedBase = path.resolve(instance.basePath);
const allowedPaths = [
path.resolve(env.INSTANCES_BASE_PATH),
...(env.CML_SOURCE_PATH ? [path.resolve(env.CML_SOURCE_PATH)] : []),
];
const isAllowed = allowedPaths.some(
(allowed) => resolvedBase === allowed || resolvedBase.startsWith(allowed + '/')
);
if (!isAllowed) {
throw new AppError(400, 'Instance path is outside the allowed directory', 'INVALID_PATH');
}
const envPath = path.join(resolvedBase, '.env');
let envVars: Record<string, string> | null = null;
try {
const content = await fs.readFile(envPath, 'utf-8');
envVars = parseDotenv(Buffer.from(content));
} catch {
envVars = null;
}
if (!envVars) {
throw new AppError(400, 'Could not read .env file for this instance', 'ENV_NOT_FOUND');
}
return {
initialAdminEmail: envVars.INITIAL_ADMIN_EMAIL || instance.adminEmail,
initialAdminPassword: envVars.INITIAL_ADMIN_PASSWORD || null,
};
}
// ─── Lifecycle Operations ────────────────────────────────────────────
export async function provisionInstance(id: string, userId: string, ipAddress?: string) {
// Registered instances cannot be provisioned
const check = await prisma.instance.findUnique({ where: { id }, select: { isRegistered: true } });
if (check?.isRegistered) {
throw new AppError(400, 'Cannot provision a registered instance', 'NOT_MANAGED');
}
// Atomic check-and-update to prevent concurrent provisioning
const { count } = await prisma.instance.updateMany({
where: { id, status: { in: [InstanceStatus.ERROR, InstanceStatus.STOPPED] } },
data: { status: InstanceStatus.PROVISIONING, statusMessage: 'Retrying provisioning...' },
});
if (count === 0) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
throw new AppError(400, `Cannot provision instance in ${instance.status} state`, 'INVALID_STATE');
}
await prisma.auditLog.create({
data: {
userId,
instanceId: id,
action: AuditAction.INSTANCE_UPDATE,
details: { event: 'provision_retry' },
ipAddress,
},
});
// Fire-and-forget
provision(id).catch((err) => {
logger.error(`[instances] Re-provisioning failed for ${id}: ${err}`);
});
return { message: 'Provisioning started' };
}
export async function startInstance(id: string, userId: string, ipAddress?: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
if (instance.status !== 'STOPPED' && instance.status !== 'ERROR') {
throw new AppError(400, `Cannot start instance in ${instance.status} state`, 'INVALID_STATE');
}
try {
await docker.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
data: { status: InstanceStatus.RUNNING, statusMessage: 'All containers started' },
});
await prisma.auditLog.create({
data: {
userId,
instanceId: id,
action: AuditAction.INSTANCE_START,
details: { slug: instance.slug },
ipAddress,
},
});
return { message: 'Instance started' };
} catch (err) {
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
data: { status: InstanceStatus.ERROR, statusMessage: `Start failed: ${errorMsg}` },
});
throw new AppError(500, `Failed to start instance: ${errorMsg}`, 'DOCKER_ERROR');
}
}
export async function stopInstance(id: string, userId: string, ipAddress?: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
if (instance.status !== 'RUNNING' && instance.status !== 'ERROR') {
throw new AppError(400, `Cannot stop instance in ${instance.status} state`, 'INVALID_STATE');
}
try {
await docker.composeStop(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
data: { status: InstanceStatus.STOPPED, statusMessage: 'All containers stopped' },
});
await prisma.auditLog.create({
data: {
userId,
instanceId: id,
action: AuditAction.INSTANCE_STOP,
details: { slug: instance.slug },
ipAddress,
},
});
return { message: 'Instance stopped' };
} catch (err) {
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to stop instance: ${errorMsg}`, 'DOCKER_ERROR');
}
}
export async function restartInstance(id: string, userId: string, ipAddress?: string, service?: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
try {
await docker.composeRestart(instance.basePath, instance.composeProject, service);
await prisma.auditLog.create({
data: {
userId,
instanceId: id,
action: AuditAction.INSTANCE_RESTART,
details: { slug: instance.slug, service: service || 'all' },
ipAddress,
},
});
return { message: `${service || 'All services'} restarted` };
} catch (err) {
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to restart: ${errorMsg}`, 'DOCKER_ERROR');
}
}
export async function getInstanceServices(id: string) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
try {
return await docker.composePs(instance.basePath, instance.composeProject);
} catch {
// If compose ps fails (e.g. no containers), return empty array
return [];
}
}
export async function getInstanceLogs(
id: string,
service?: string,
tail = 200,
since?: string
) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
try {
return await docker.composeLogs(
instance.basePath,
instance.composeProject,
service,
tail,
since
);
} catch (err) {
throw new AppError(500, `Failed to get logs: ${(err as Error).message}`, 'DOCKER_ERROR');
}
}
// ─── Reconfiguration ─────────────────────────────────────────────────
export async function reconfigureInstance(
id: string,
features: ReconfigureInstanceInput,
userId: string,
ipAddress?: string
) {
const instance = await prisma.instance.findUnique({ where: { id } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
if (instance.isRegistered) {
throw new AppError(400, 'Cannot reconfigure an external instance', 'NOT_MANAGED');
}
if (!instance.encryptedSecrets) {
throw new AppError(400, 'Instance has no secrets — cannot reconfigure', 'NOT_MANAGED');
}
if (instance.status !== 'RUNNING' && instance.status !== 'STOPPED') {
throw new AppError(400, `Cannot reconfigure instance in ${instance.status} state`, 'INVALID_STATE');
}
// Update feature flags in DB
const updated = await prisma.instance.update({
where: { id },
data: {
...features,
statusMessage: 'Reconfiguring...',
},
});
// Clear template cache so updated templates are re-read
clearTemplateCache();
// Re-render templates with updated flags
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
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);
// --remove-orphans (from composeUp) will clean up disabled services
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Reconfiguration complete' },
});
} catch (err) {
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
data: { statusMessage: `Reconfiguration failed: ${errorMsg}` },
});
throw new AppError(500, `Reconfiguration failed: ${errorMsg}`, 'DOCKER_ERROR');
}
} else {
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Reconfiguration complete — start instance to apply' },
});
}
// Audit log
await prisma.auditLog.create({
data: {
userId,
instanceId: id,
action: AuditAction.INSTANCE_UPDATE,
details: { event: 'reconfigure', features } as unknown as Prisma.InputJsonValue,
ipAddress,
},
});
return updated;
}

View File

@@ -0,0 +1,197 @@
import { InstanceStatus, AuditAction } from '@prisma/client';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import { prisma } from '../../lib/prisma';
import { env } from '../../config/env';
import { decryptJson } from '../../utils/encryption';
import { renderAllTemplates, buildTemplateContext } from '../../services/template-engine';
import * as docker from '../../services/docker.service';
import { logger } from '../../utils/logger';
const exec = promisify(execCb);
/**
* Directories/files to exclude when copying CML source to instance directory.
*/
const COPY_EXCLUDES = [
'node_modules',
'.git',
'.env',
'changemaker-control-panel',
'.claude',
];
/**
* Update instance status and statusMessage in the database.
*/
async function updateStatus(
instanceId: string,
status: InstanceStatus,
statusMessage: string
): Promise<void> {
await prisma.instance.update({
where: { id: instanceId },
data: { status, statusMessage },
});
}
/**
* Provision a CML instance: copy source, render configs, build and start Docker stack.
*
* This function runs asynchronously — call it without awaiting.
* Progress is tracked via instance.status and instance.statusMessage.
*/
export async function provision(instanceId: string): Promise<void> {
const totalSteps = 13;
let step = 0;
function stepMsg(description: string): string {
step++;
return `Step ${step}/${totalSteps}: ${description}`;
}
try {
// Load instance with all details
const instance = await prisma.instance.findUnique({
where: { id: instanceId },
include: { portAllocations: true },
});
if (!instance) {
throw new Error(`Instance ${instanceId} not found`);
}
const { basePath, composeProject } = instance;
const instanceDir = path.dirname(basePath); // parent of changemaker.lite
// ── Step 1: Create instance directory ───────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Creating instance directory'));
logger.info(`[provisioner] ${instance.slug}: Creating directory ${basePath}`);
await fs.mkdir(basePath, { recursive: true });
// ── Step 2: Copy CML source ────────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Copying CML source'));
logger.info(`[provisioner] ${instance.slug}: Copying from ${env.CML_SOURCE_PATH} to ${basePath}`);
const excludeFlags = COPY_EXCLUDES.map((e) => `--exclude='${e}'`).join(' ');
await exec(
`rsync -a ${excludeFlags} ${env.CML_SOURCE_PATH}/ ${basePath}/`,
{ timeout: 120_000 }
);
// ── Step 2b: Create media directories ──────────────────────────
// Media API volume mounts use ./media as the read-only base with
// rw overlays for subdirectories. All must exist before Docker starts.
const mediaDirs = [
'media/local/inbox',
'media/local/thumbnails',
'media/local/photos',
'media/public',
];
for (const dir of mediaDirs) {
await fs.mkdir(path.join(basePath, dir), { recursive: true });
}
logger.info(`[provisioner] ${instance.slug}: Created media directories`);
// ── Step 3: Decrypt secrets ────────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Preparing configuration'));
if (!instance.encryptedSecrets) {
throw new Error('Instance has no encrypted secrets — cannot provision');
}
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
// ── Step 4: Build template context ─────────────────────────────
const context = buildTemplateContext(instance, secrets);
// ── Step 5: Render templates ───────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Rendering configuration files'));
logger.info(`[provisioner] ${instance.slug}: Rendering templates to ${basePath}`);
await renderAllTemplates(context, basePath);
// ── Step 6: Pull base images ───────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Pulling Docker images'));
logger.info(`[provisioner] ${instance.slug}: Pulling images`);
try {
await docker.composePull(basePath, composeProject);
} catch (err) {
// Pull failures are non-fatal if images already exist locally
logger.warn(`[provisioner] ${instance.slug}: Pull warning: ${(err as Error).message}`);
}
// ── Step 7: Build custom images ────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Building Docker images'));
logger.info(`[provisioner] ${instance.slug}: Building images`);
await docker.composeBuild(basePath, composeProject);
// ── Step 8: Start infrastructure (Postgres + Redis) ────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting database and cache'));
logger.info(`[provisioner] ${instance.slug}: Starting infrastructure services`);
await docker.composeUp(basePath, composeProject, ['v2-postgres', 'redis']);
// ── Step 9: Wait for infrastructure healthy ────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Waiting for database to be ready'));
logger.info(`[provisioner] ${instance.slug}: Waiting for Postgres + Redis healthy`);
// Container names match template's container_name: {{containerPrefix}}-postgres / {{containerPrefix}}-redis
const pgContainer = `${composeProject}-postgres`;
const redisContainer = `${composeProject}-redis`;
await Promise.all([
docker.waitForHealthy(pgContainer, 60_000),
docker.waitForHealthy(redisContainer, 60_000),
]);
// ── Step 10: Run database schema sync ─────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Setting up database schema'));
logger.info(`[provisioner] ${instance.slug}: Pushing Prisma schema to database`);
// Use composeRun with --entrypoint "" to skip the API's startup entrypoint
// (which would try migrate deploy + seed and fail on schema drift)
await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db push --accept-data-loss', 180_000);
// ── Step 11: Seed database ─────────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Seeding database'));
logger.info(`[provisioner] ${instance.slug}: Seeding database`);
await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db seed', 120_000);
// ── Step 12: Start all services ────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting all services'));
logger.info(`[provisioner] ${instance.slug}: Starting all services`);
await docker.composeUp(basePath, composeProject);
// ── Step 13: Health check ──────────────────────────────────────
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Verifying instance health'));
logger.info(`[provisioner] ${instance.slug}: Waiting for API health check`);
const ports = instance.portConfig as Record<string, number>;
// Use host.docker.internal to reach ports exposed on the Docker host
// (localhost inside the CCP container refers to the CCP container itself)
await docker.waitForHttp(`http://host.docker.internal:${ports.api}/api/health`, 120_000);
// ── Done! ──────────────────────────────────────────────────────
await updateStatus(instanceId, InstanceStatus.RUNNING, 'Provisioning complete');
logger.info(`[provisioner] ${instance.slug}: Provisioning complete!`);
// Audit log
await prisma.auditLog.create({
data: {
instanceId,
action: AuditAction.INSTANCE_CREATE,
details: { event: 'provisioning_complete', slug: instance.slug },
},
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
logger.error(`[provisioner] ${instanceId}: Failed — ${errorMsg}`);
await updateStatus(
instanceId,
InstanceStatus.ERROR,
`Provisioning failed at ${step > 0 ? `step ${step}/${totalSteps}` : 'startup'}: ${errorMsg}`
).catch(() => {});
// Audit log
await prisma.auditLog.create({
data: {
instanceId,
action: AuditAction.INSTANCE_CREATE,
details: { event: 'provisioning_failed', error: errorMsg, step },
},
}).catch(() => {});
}
}

View File

@@ -0,0 +1,43 @@
import { Router, Request, Response } from 'express';
import { AuditAction } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { authenticate, requireRole } from '../../middleware/auth';
const router = Router();
router.use(authenticate);
router.get('/', async (_req: Request, res: Response) => {
const settings = await prisma.ccpSetting.findMany();
const map: Record<string, unknown> = {};
for (const s of settings) {
map[s.key] = s.value;
}
res.json({ data: map });
});
router.put(
'/:key',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const { key } = req.params as { key: string };
const { value } = req.body;
const setting = await prisma.ccpSetting.upsert({
where: { key },
update: { value },
create: { key, value },
});
await prisma.auditLog.create({
data: {
userId: req.user!.id,
action: AuditAction.SETTINGS_UPDATE,
details: { key, value },
ipAddress: req.ip,
},
});
res.json({ data: setting });
}
);
export default router;

View File

@@ -0,0 +1,66 @@
import 'express-async-errors';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import rateLimit from 'express-rate-limit';
import { env } from './config/env';
import { logger } from './utils/logger';
import { errorHandler } from './middleware/error-handler';
// Route imports
import authRoutes from './modules/auth/auth.routes';
import instanceRoutes from './modules/instances/instances.routes';
import settingsRoutes from './modules/settings/settings.routes';
import healthRoutes from './modules/health/health.routes';
import auditRoutes from './modules/audit/audit.routes';
import backupRoutes from './modules/backups/backup.routes';
import { startHealthScheduler } from './services/health.service';
import { autoDiscoverOnStartup } from './services/discovery.service';
const app = express();
// Global middleware
app.use(helmet());
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(
cors({
origin: env.CORS_ORIGINS.split(',').map((s) => s.trim()),
credentials: true,
})
);
// Rate limiters
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 15, // 15 attempts per window
standardHeaders: true,
legacyHeaders: false,
message: { error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } },
});
// Routes
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/instances', instanceRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/health', healthRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/backups', backupRoutes);
// Error handler (must be last)
app.use(errorHandler);
app.listen(env.PORT, () => {
logger.info(`CCP API listening on port ${env.PORT} (${env.NODE_ENV})`);
startHealthScheduler(env.HEALTH_CHECK_INTERVAL_MS);
// Auto-discover parent CML instance on first boot (5s delay for DB readiness)
setTimeout(() => {
autoDiscoverOnStartup().catch((err) =>
logger.error(`[discovery] Auto-discovery failed: ${(err as Error).message}`)
);
}, 5_000);
});
export default app;

View File

@@ -0,0 +1,313 @@
import { Prisma, BackupStatus, AuditAction, InstanceStatus } from '@prisma/client';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import { prisma } from '../lib/prisma';
import { env } from '../config/env';
import { AppError } from '../middleware/error-handler';
import { decryptJson } from '../utils/encryption';
import * as docker from './docker.service';
import { logger } from '../utils/logger';
const exec = promisify(execCb);
/**
* Compute SHA-256 hash of a file.
*/
async function fileHash(filePath: string): Promise<string> {
const fileBuffer = await fs.readFile(filePath);
return crypto.createHash('sha256').update(fileBuffer).digest('hex');
}
/**
* Get file size in bytes.
*/
async function fileSize(filePath: string): Promise<number> {
const stat = await fs.stat(filePath);
return stat.size;
}
/**
* Create a backup for a given instance.
*/
export async function createBackup(instanceId: string, userId?: string, ipAddress?: string) {
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
if (!instance) {
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
}
if (instance.status !== InstanceStatus.RUNNING) {
throw new AppError(400, `Cannot backup instance in ${instance.status} state`, 'INVALID_STATE');
}
if ((instance as { isRegistered?: boolean }).isRegistered) {
throw new AppError(400, 'Backups not managed by CCP for registered instances', 'NOT_MANAGED');
}
// Create backup record
const backup = await prisma.backup.create({
data: {
instanceId,
status: BackupStatus.PENDING,
},
});
// Run backup asynchronously
performBackup(backup.id, instance, userId, ipAddress).catch((err) => {
logger.error(`[backup] Backup ${backup.id} failed: ${(err as Error).message}`);
});
return backup;
}
async function performBackup(
backupId: string,
instance: { id: string; slug: string; basePath: string; composeProject: string; encryptedSecrets: string | null },
userId?: string,
ipAddress?: string
) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = path.join(env.BACKUP_STORAGE_PATH, instance.slug, timestamp);
try {
// Update status to IN_PROGRESS
await prisma.backup.update({
where: { id: backupId },
data: { status: BackupStatus.IN_PROGRESS },
});
// Ensure backup directory exists
await fs.mkdir(backupDir, { recursive: true });
const manifestFiles: Array<{ name: string; size: number; sha256: string }> = [];
// 1. Dump PostgreSQL
try {
const secrets = instance.encryptedSecrets
? decryptJson<Record<string, string>>(instance.encryptedSecrets)
: {} as Record<string, string>;
const pgPassword = secrets.V2_POSTGRES_PASSWORD || secrets.postgresPassword || 'changemaker';
// Use docker compose exec to run pg_dump inside the container
// Pass PGPASSWORD via -e flag so pg_dump can authenticate
const dumpOutput = await docker.composeExec(
instance.basePath,
instance.composeProject,
'v2-postgres',
`pg_dump -U changemaker -d changemaker`,
300_000, // 5 min timeout for large DBs
{ PGPASSWORD: pgPassword }
);
const dumpPath = path.join(backupDir, 'v2-postgres.sql');
await fs.writeFile(dumpPath, dumpOutput);
// Gzip the dump
await exec(`gzip "${dumpPath}"`, { timeout: 120_000 });
const gzPath = dumpPath + '.gz';
manifestFiles.push({
name: 'v2-postgres.sql.gz',
size: await fileSize(gzPath),
sha256: await fileHash(gzPath),
});
logger.info(`[backup] ${instance.slug}: PostgreSQL dump complete`);
} catch (err) {
logger.warn(`[backup] ${instance.slug}: PostgreSQL dump failed: ${(err as Error).message}`);
// Continue with backup — mark the dump as failed in manifest
}
// 2. Archive uploads if they exist
const uploadsDir = path.join(instance.basePath, 'uploads');
try {
await fs.access(uploadsDir);
const uploadsArchive = path.join(backupDir, 'uploads.tar.gz');
await exec(`tar -czf "${uploadsArchive}" -C "${instance.basePath}" uploads`, { timeout: 300_000 });
manifestFiles.push({
name: 'uploads.tar.gz',
size: await fileSize(uploadsArchive),
sha256: await fileHash(uploadsArchive),
});
logger.info(`[backup] ${instance.slug}: Uploads archive complete`);
} catch {
// No uploads directory or archive failed — skip
logger.debug(`[backup] ${instance.slug}: No uploads directory or archive skipped`);
}
// 3. Generate manifest
const manifest = {
instanceId: instance.id,
instanceSlug: instance.slug,
timestamp: new Date().toISOString(),
files: manifestFiles,
};
const manifestPath = path.join(backupDir, 'manifest.json');
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
// 4. Create final archive
const archiveName = `backup-${instance.slug}-${timestamp}.tar.gz`;
const archivePath = path.join(env.BACKUP_STORAGE_PATH, instance.slug, archiveName);
await exec(`tar -czf "${archivePath}" -C "${path.dirname(backupDir)}" "${path.basename(backupDir)}"`, {
timeout: 300_000,
});
const totalSize = await fileSize(archivePath);
// Cleanup the temp directory
await fs.rm(backupDir, { recursive: true, force: true });
// Update backup record
await prisma.backup.update({
where: { id: backupId },
data: {
status: BackupStatus.COMPLETED,
archivePath,
sizeBytes: BigInt(totalSize),
manifest: manifest as unknown as Prisma.InputJsonValue,
completedAt: new Date(),
},
});
// Audit log
if (userId) {
await prisma.auditLog.create({
data: {
userId,
instanceId: instance.id,
action: AuditAction.BACKUP_CREATE,
details: { backupId, archiveName, sizeBytes: totalSize },
ipAddress,
},
});
}
logger.info(`[backup] ${instance.slug}: Backup complete (${(totalSize / 1024 / 1024).toFixed(1)} MB)`);
} catch (err) {
// Update backup as failed
await prisma.backup.update({
where: { id: backupId },
data: {
status: BackupStatus.FAILED,
errorMessage: (err as Error).message,
completedAt: new Date(),
},
});
// Cleanup temp directory on failure
try {
await fs.rm(backupDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw err;
}
}
/**
* Delete a backup (file + DB record).
*/
export async function deleteBackup(backupId: string, userId?: string, ipAddress?: string) {
const backup = await prisma.backup.findUnique({
where: { id: backupId },
include: { instance: { select: { id: true, slug: true } } },
});
if (!backup) {
throw new AppError(404, 'Backup not found', 'NOT_FOUND');
}
// Delete archive file
if (backup.archivePath) {
try {
await fs.unlink(backup.archivePath);
} catch {
logger.warn(`[backup] Could not delete file: ${backup.archivePath}`);
}
}
await prisma.backup.delete({ where: { id: backupId } });
if (userId) {
await prisma.auditLog.create({
data: {
userId,
instanceId: backup.instanceId,
action: AuditAction.BACKUP_DELETE,
details: { backupId, instanceSlug: backup.instance?.slug },
ipAddress,
},
});
}
}
/**
* List backups with optional instance filter and pagination.
*/
export async function listBackups(instanceId?: string, page = 1, limit = 50) {
const where = instanceId ? { instanceId } : {};
const [data, total] = await Promise.all([
prisma.backup.findMany({
where,
orderBy: { startedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
include: {
instance: { select: { id: true, name: true, slug: true } },
},
}),
prisma.backup.count({ where }),
]);
return { data, total, page, limit };
}
/**
* Get a single backup by ID.
*/
export async function getBackup(backupId: string) {
const backup = await prisma.backup.findUnique({ where: { id: backupId } });
if (!backup) {
throw new AppError(404, 'Backup not found', 'NOT_FOUND');
}
return backup;
}
/**
* Cleanup backups older than retention period.
*/
export async function cleanupOldBackups(retentionDays: number): Promise<number> {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
const oldBackups = await prisma.backup.findMany({
where: {
startedAt: { lt: cutoff },
status: { in: [BackupStatus.COMPLETED, BackupStatus.FAILED] },
},
});
let deleted = 0;
for (const backup of oldBackups) {
try {
if (backup.archivePath) {
await fs.unlink(backup.archivePath);
}
await prisma.backup.delete({ where: { id: backup.id } });
deleted++;
} catch (err) {
logger.warn(`[backup] Failed to cleanup backup ${backup.id}: ${(err as Error).message}`);
}
}
if (deleted > 0) {
logger.info(`[backup] Cleaned up ${deleted} old backups (>${retentionDays} days)`);
}
return deleted;
}

View File

@@ -0,0 +1,388 @@
import fs from 'fs/promises';
import path from 'path';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import { parse as parseDotenv } from 'dotenv';
import { prisma } from '../lib/prisma';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { registerInstance } from '../modules/instances/instances.service';
import * as docker from './docker.service';
const exec = promisify(execCb);
// ─── Types ──────────────────────────────────────────────────────────
export interface DiscoveredInstance {
name: string;
slug: string;
domain: string;
basePath: string;
composeProject: string;
portConfig: { api: number; admin: number; postgres: number; nginx: number };
adminEmail: string;
enableMedia: boolean;
enableChat: boolean;
enableGancio: boolean;
enableListmonk: boolean;
enableMonitoring: boolean;
enableDevTools: boolean;
enablePayments: boolean;
emailTestMode: boolean;
// Discovery metadata (UI-only, not persisted)
source: 'parent' | 'docker';
isRunning: boolean;
runningContainers: number;
totalContainers: number;
isAlreadyRegistered: boolean;
existingInstanceId?: string;
isParentInstance: boolean;
}
export interface DiscoverySummary {
total: number;
newInstances: number;
alreadyRegistered: number;
running: number;
parentFound: boolean;
}
export interface DiscoveryResult {
instances: DiscoveredInstance[];
summary: DiscoverySummary;
}
interface ComposeProject {
Name: string;
Status: string;
ConfigFiles: string;
}
// ─── .env Parser ────────────────────────────────────────────────────
/**
* Parse a CML instance's .env file and extract safe configuration metadata.
* Never reads secrets (JWT keys, passwords, encryption keys).
*/
async function parseCmlEnv(envPath: string): Promise<Record<string, string> | null> {
try {
const content = await fs.readFile(envPath, 'utf-8');
return parseDotenv(Buffer.from(content));
} catch {
return null;
}
}
function extractPortConfig(envVars: Record<string, string>): DiscoveredInstance['portConfig'] {
return {
api: parseInt(envVars.API_PORT || '4000', 10),
admin: parseInt(envVars.ADMIN_PORT || '3000', 10),
postgres: parseInt(envVars.V2_POSTGRES_PORT || '5433', 10),
nginx: parseInt(envVars.NGINX_HTTP_PORT || '80', 10),
};
}
function extractFeatureFlags(envVars: Record<string, string>) {
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),
emailTestMode: isTrue(envVars.EMAIL_TEST_MODE),
};
}
function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50);
}
// ─── CML Fingerprint Test ───────────────────────────────────────────
/**
* Check if a directory is a CML instance by verifying key files exist
* and the .env has CML-specific variables.
*/
async function isCmlInstance(dirPath: string): Promise<boolean> {
try {
// Must have docker-compose.yml and api/prisma/schema.prisma
await fs.access(path.join(dirPath, 'docker-compose.yml'));
await fs.access(path.join(dirPath, 'api', 'prisma', 'schema.prisma'));
// Must have .env with DOMAIN and JWT_ACCESS_SECRET
const envVars = await parseCmlEnv(path.join(dirPath, '.env'));
if (!envVars) return false;
return !!(envVars.DOMAIN && envVars.JWT_ACCESS_SECRET);
} catch {
return false;
}
}
// ─── Container Status ───────────────────────────────────────────────
async function getContainerCounts(
projectDir: string,
composeProject: string
): Promise<{ running: number; total: number }> {
try {
// Use docker.composePs which validates the project name
const containers = await docker.composePs(projectDir, composeProject);
const running = containers.filter((c) => c.state === 'running').length;
return { running, total: containers.length };
} catch {
return { running: 0, total: 0 };
}
}
// ─── Compose Project Discovery ──────────────────────────────────────
/**
* List all Docker Compose projects on the host via `docker compose ls`.
*/
async function listComposeProjects(): Promise<ComposeProject[]> {
try {
const { stdout } = await exec('docker compose ls --format json', {
timeout: 15_000,
env: { ...process.env, COMPOSE_ANSI: 'never' },
});
if (!stdout.trim()) return [];
return JSON.parse(stdout);
} catch (err) {
logger.warn(`[discovery] Failed to list compose projects: ${(err as Error).message}`);
return [];
}
}
/**
* Derive the compose project name from a directory path.
* Docker Compose defaults to the directory basename (lowercased, special chars replaced).
*/
function deriveComposeProject(dirPath: string, projects: ComposeProject[]): string {
// Try to find project by matching config file path
const composePath = path.join(dirPath, 'docker-compose.yml');
for (const p of projects) {
// ConfigFiles can be comma-separated
const configs = p.ConfigFiles.split(',').map((s) => s.trim());
if (configs.some((c) => path.resolve(c) === path.resolve(composePath))) {
return p.Name;
}
}
// Fall back to directory basename convention
return path.basename(dirPath).toLowerCase().replace(/[^a-z0-9]/g, '');
}
// ─── Build Discovered Instance ──────────────────────────────────────
async function buildDiscoveredInstance(
dirPath: string,
source: 'parent' | 'docker',
composeProject: string,
existingInstances: Array<{ id: string; basePath: string; composeProject: string; domain: string }>
): Promise<DiscoveredInstance | null> {
const envVars = await parseCmlEnv(path.join(dirPath, '.env'));
if (!envVars) return null;
const domain = envVars.DOMAIN || path.basename(dirPath);
const portConfig = extractPortConfig(envVars);
const flags = extractFeatureFlags(envVars);
// Check container status
const { running, total } = await getContainerCounts(dirPath, composeProject);
// Infer monitoring/devtools from running containers (these aren't .env flags)
let enableMonitoring = false;
let enableDevTools = false;
try {
// Use docker.composePs which validates the project name (prevents shell injection)
const containers = await docker.composePs(dirPath, composeProject);
const serviceNames = containers.map((c) => c.service.toLowerCase());
enableMonitoring = serviceNames.some((n) => n.includes('prometheus') || n.includes('grafana'));
enableDevTools = serviceNames.some((n) => n.includes('code-server') || n.includes('gitea') || n.includes('n8n'));
} catch {
// Couldn't inspect services — leave as false
}
// Check deduplication against existing instances
const resolvedPath = path.resolve(dirPath);
const match = existingInstances.find(
(inst) =>
path.resolve(inst.basePath) === resolvedPath ||
inst.composeProject === composeProject ||
inst.domain === domain
);
const isParent = source === 'parent' ||
(!!env.CML_SOURCE_PATH && path.resolve(env.CML_SOURCE_PATH) === resolvedPath);
return {
name: envVars.SITE_NAME || domain.split('.')[0] || path.basename(dirPath),
slug: slugify(envVars.SITE_NAME || domain.split('.')[0] || path.basename(dirPath)),
domain,
basePath: resolvedPath,
composeProject,
portConfig,
adminEmail: envVars.INITIAL_ADMIN_EMAIL || 'admin@localhost',
...flags,
enableMonitoring,
enableDevTools,
source,
isRunning: running > 0,
runningContainers: running,
totalContainers: total,
isAlreadyRegistered: !!match,
existingInstanceId: match?.id,
isParentInstance: isParent,
};
}
// ─── Main Discovery Function ────────────────────────────────────────
export async function discoverInstances(): Promise<DiscoveryResult> {
const discovered: DiscoveredInstance[] = [];
const seenPaths = new Set<string>();
// Load existing instances for deduplication
const existingInstances = await prisma.instance.findMany({
select: { id: true, basePath: true, composeProject: true, domain: true },
});
// List all compose projects upfront
const composeProjects = await listComposeProjects();
// Strategy 1: Parent instance (from CML_SOURCE_PATH)
if (env.CML_SOURCE_PATH) {
const parentPath = path.resolve(env.CML_SOURCE_PATH);
if (await isCmlInstance(parentPath)) {
const project = deriveComposeProject(parentPath, composeProjects);
const inst = await buildDiscoveredInstance(parentPath, 'parent', project, existingInstances);
if (inst) {
// Ensure parent gets a sensible name
if (inst.isParentInstance && inst.name === path.basename(parentPath)) {
inst.name = `Parent (${inst.domain})`;
}
discovered.push(inst);
seenPaths.add(parentPath);
}
} else {
logger.debug(`[discovery] CML_SOURCE_PATH (${env.CML_SOURCE_PATH}) is not a valid CML instance`);
}
}
// Strategy 2: Docker scan — check all running compose projects
for (const project of composeProjects) {
// Skip CCP's own project
if (project.Name.startsWith('ccp-') || project.Name.startsWith('changemaker-control-panel')) {
continue;
}
// Extract project directory from config file path
if (!project.ConfigFiles) continue;
const configFile = project.ConfigFiles.split(',')[0].trim();
const projectDir = path.dirname(configFile);
const resolvedDir = path.resolve(projectDir);
// Skip if already found via parent strategy
if (seenPaths.has(resolvedDir)) continue;
// CML fingerprint test
if (!(await isCmlInstance(resolvedDir))) continue;
const inst = await buildDiscoveredInstance(resolvedDir, 'docker', project.Name, existingInstances);
if (inst) {
discovered.push(inst);
seenPaths.add(resolvedDir);
}
}
// Build summary
const newInstances = discovered.filter((d) => !d.isAlreadyRegistered).length;
const alreadyRegistered = discovered.filter((d) => d.isAlreadyRegistered).length;
const running = discovered.filter((d) => d.isRunning).length;
const parentFound = discovered.some((d) => d.isParentInstance);
return {
instances: discovered,
summary: {
total: discovered.length,
newInstances,
alreadyRegistered,
running,
parentFound,
},
};
}
// ─── Auto-Import on First Boot ──────────────────────────────────────
/**
* Checks if the CCP database has zero instances. If empty, discovers
* the parent instance and auto-registers it.
* Called 5s after server startup via setTimeout.
*/
export async function autoDiscoverOnStartup(): Promise<void> {
const count = await prisma.instance.count();
if (count > 0) {
logger.debug('[discovery] Instances already exist, skipping auto-discovery');
return;
}
logger.info('[discovery] No instances found — running auto-discovery...');
const result = await discoverInstances();
if (result.instances.length === 0) {
logger.info('[discovery] No CML instances discovered on this host');
return;
}
// Auto-register parent instance first, then any others
const sorted = [...result.instances].sort((a, b) => {
if (a.isParentInstance && !b.isParentInstance) return -1;
if (!a.isParentInstance && b.isParentInstance) return 1;
return 0;
});
// Get or create a system user ID for audit logging
const systemUser = await prisma.ccpUser.findFirst({
where: { role: 'SUPER_ADMIN' },
select: { id: true },
});
const userId = systemUser?.id || 'system';
let imported = 0;
for (const inst of sorted) {
if (inst.isAlreadyRegistered) continue;
try {
await registerInstance(
{
name: inst.name,
slug: inst.slug,
domain: inst.domain,
basePath: inst.basePath,
composeProject: inst.composeProject,
portConfig: inst.portConfig,
adminEmail: inst.adminEmail,
enableMedia: inst.enableMedia,
enableChat: inst.enableChat,
enableGancio: inst.enableGancio,
enableListmonk: inst.enableListmonk,
enableMonitoring: inst.enableMonitoring,
enableDevTools: inst.enableDevTools,
enablePayments: inst.enablePayments,
},
userId,
'auto-discovery'
);
imported++;
logger.info(`[discovery] Auto-registered instance: ${inst.name} (${inst.domain})`);
} catch (err) {
logger.warn(`[discovery] Failed to auto-register ${inst.name}: ${(err as Error).message}`);
}
}
logger.info(`[discovery] Auto-discovery complete: ${imported}/${sorted.filter((s) => !s.isAlreadyRegistered).length} instances registered`);
}

View File

@@ -0,0 +1,351 @@
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import http from 'http';
import { logger } from '../utils/logger';
const exec = promisify(execCb);
const EXEC_TIMEOUT = 120_000; // 2 minutes
const DOCKER_SOCKET = '/var/run/docker.sock';
/** Validate a service/project name to prevent shell injection. */
function validateName(name: string, label: string): string {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid ${label}: ${name}`);
}
return name;
}
/** Validate a Docker duration format (e.g., "1h", "30m", "24h"). */
function validateDuration(value: string): string {
if (!/^\d+[smhd]$/.test(value)) {
throw new Error(`Invalid duration: ${value}`);
}
return value;
}
/** Validate a tail count (positive integer, capped). */
function validateTail(value: number): number {
const n = Math.max(1, Math.min(value, 5000));
return Math.floor(n);
}
/** Parsed container status from `docker compose ps --format json` */
export interface ContainerInfo {
name: string;
service: string;
status: string;
state: string;
health: string;
ports: string;
createdAt: string;
exitCode: number;
}
/**
* Execute a shell command with timeout and proper error handling.
*/
async function execCmd(
command: string,
cwd: string,
timeoutMs = EXEC_TIMEOUT
): Promise<{ stdout: string; stderr: string }> {
logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`);
try {
const result = await exec(command, {
cwd,
timeout: timeoutMs,
maxBuffer: 10 * 1024 * 1024, // 10MB
env: { ...process.env, COMPOSE_ANSI: 'never' },
});
return result;
} catch (err: unknown) {
const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
if (error.killed) {
throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`);
}
// Include stderr in the error for debugging
const msg = error.stderr || error.message || 'Unknown exec error';
throw new Error(`Command failed: ${command}\n${msg}`);
}
}
/**
* Build the base compose command with project name.
*/
function composeCmd(project: string): string {
return `docker compose -p ${validateName(project, 'project')}`;
}
// ─── Docker Compose CLI Operations ───────────────────────────────────
export async function composeUp(
projectDir: string,
project: string,
services?: string[]
): Promise<string> {
const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : '';
const orphanFlag = services?.length ? '' : ' --remove-orphans';
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} up -d${orphanFlag}${svc}`,
projectDir
);
return stdout || stderr;
}
export async function composeDown(
projectDir: string,
project: string,
removeVolumes = false
): Promise<string> {
const flags = removeVolumes ? ' -v' : '';
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} down${flags}`,
projectDir
);
return stdout || stderr;
}
export async function composeStop(
projectDir: string,
project: string
): Promise<string> {
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} stop`,
projectDir
);
return stdout || stderr;
}
export async function composeRestart(
projectDir: string,
project: string,
service?: string
): Promise<string> {
const svc = service ? ` ${validateName(service, 'service')}` : '';
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} restart${svc}`,
projectDir
);
return stdout || stderr;
}
export async function composePull(
projectDir: string,
project: string
): Promise<string> {
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} pull`,
projectDir,
300_000 // 5 min for pulls
);
return stdout || stderr;
}
export async function composeBuild(
projectDir: string,
project: string
): Promise<string> {
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} build`,
projectDir,
600_000 // 10 min for builds
);
return stdout || stderr;
}
/**
* List containers with status. Returns parsed container info.
*/
export async function composePs(
projectDir: string,
project: string
): Promise<ContainerInfo[]> {
const { stdout } = await execCmd(
`${composeCmd(project)} ps --format json`,
projectDir
);
if (!stdout.trim()) return [];
// docker compose ps --format json outputs one JSON object per line
const containers: ContainerInfo[] = [];
for (const line of stdout.trim().split('\n')) {
if (!line.trim()) continue;
try {
const raw = JSON.parse(line);
containers.push({
name: raw.Name || raw.name || '',
service: raw.Service || raw.service || '',
status: raw.Status || raw.status || '',
state: raw.State || raw.state || '',
health: raw.Health || raw.health || '',
ports: raw.Ports || raw.ports || '',
createdAt: raw.CreatedAt || raw.created_at || '',
exitCode: raw.ExitCode ?? raw.exit_code ?? 0,
});
} catch {
logger.warn(`[docker] Failed to parse container line: ${line}`);
}
}
return containers;
}
/**
* Get logs from a specific service.
*/
export async function composeLogs(
projectDir: string,
project: string,
service?: string,
tail = 200,
since?: string
): Promise<string> {
const parts = [composeCmd(project), 'logs', '--no-color'];
if (tail > 0) parts.push(`--tail=${validateTail(tail)}`);
if (since) parts.push(`--since=${validateDuration(since)}`);
if (service) parts.push(validateName(service, 'service'));
const { stdout, stderr } = await execCmd(parts.join(' '), projectDir);
return stdout || stderr;
}
/**
* Execute a command inside a running service container.
* Optionally pass environment variables via -e flags.
*/
export async function composeExec(
projectDir: string,
project: string,
service: string,
command: string,
timeoutMs = EXEC_TIMEOUT,
envVars?: Record<string, string>
): Promise<string> {
const envFlags = envVars
? Object.entries(envVars).map(([k, v]) => `-e ${k}=${v}`).join(' ') + ' '
: '';
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`,
projectDir,
timeoutMs
);
return stdout || stderr;
}
/**
* Run a one-off command in a service container (docker compose run).
* Uses --entrypoint "" to skip the service's entrypoint script.
* Useful for running setup commands (prisma db push, seed) without the entrypoint.
*/
export async function composeRun(
projectDir: string,
project: string,
service: string,
command: string,
timeoutMs = EXEC_TIMEOUT
): Promise<string> {
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} run --rm --no-deps -T --entrypoint "" ${validateName(service, 'service')} ${command}`,
projectDir,
timeoutMs
);
return stdout || stderr;
}
// ─── Docker Socket API ───────────────────────────────────────────────
/**
* Make a request to the Docker Engine API via Unix socket.
*/
function dockerSocketRequest(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = http.request(
{
socketPath: DOCKER_SOCKET,
path,
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`Docker API returned ${res.statusCode}: ${data}`));
} else {
resolve(data);
}
});
}
);
req.on('error', reject);
req.setTimeout(10_000, () => {
req.destroy();
reject(new Error('Docker socket request timed out'));
});
req.end();
});
}
/**
* Get status of a specific container by name via Docker socket API.
*/
export async function getContainerStatus(
containerName: string
): Promise<{ state: string; health: string; running: boolean } | null> {
try {
const data = await dockerSocketRequest(
`/containers/${encodeURIComponent(containerName)}/json`
);
const info = JSON.parse(data);
return {
state: info.State?.Status || 'unknown',
health: info.State?.Health?.Status || 'none',
running: info.State?.Running === true,
};
} catch {
return null;
}
}
/**
* Poll until a container reaches healthy state or timeout expires.
*/
export async function waitForHealthy(
containerName: string,
timeoutMs = 60_000,
pollIntervalMs = 2_000
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const status = await getContainerStatus(containerName);
if (status?.health === 'healthy') return true;
if (status?.state === 'exited' || status?.state === 'dead') {
throw new Error(`Container ${containerName} exited unexpectedly`);
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
}
/**
* Wait for an HTTP endpoint to respond with 200.
*/
export async function waitForHttp(
url: string,
timeoutMs = 120_000,
pollIntervalMs = 3_000
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
if (response.ok) return true;
} catch {
// Expected while service is starting
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`HTTP endpoint ${url} did not respond within ${timeoutMs}ms`);
}

View File

@@ -0,0 +1,191 @@
import { InstanceStatus, HealthStatus } from '@prisma/client';
import { prisma } from '../lib/prisma';
import * as docker from './docker.service';
import { logger } from '../utils/logger';
import type { ContainerInfo } from './docker.service';
/**
* Determine overall health status from container list.
*/
function determineHealth(containers: ContainerInfo[]): {
status: HealthStatus;
serviceStatus: Record<string, { state: string; health: string }>;
totalServices: number;
healthyServices: number;
} {
if (containers.length === 0) {
return { status: HealthStatus.UNKNOWN, serviceStatus: {}, totalServices: 0, healthyServices: 0 };
}
const serviceStatus: Record<string, { state: string; health: string }> = {};
let healthyCount = 0;
let runningCount = 0;
for (const c of containers) {
serviceStatus[c.service || c.name] = {
state: c.state,
health: c.health,
};
const isRunning = c.state === 'running';
if (isRunning) runningCount++;
// A service is "healthy" if it's running AND either has no health check or passes it
const isHealthy = isRunning && (c.health === '' || c.health === 'healthy');
if (isHealthy) healthyCount++;
}
const total = containers.length;
let status: HealthStatus;
if (healthyCount === total) {
status = HealthStatus.HEALTHY;
} else if (runningCount === 0) {
status = HealthStatus.UNHEALTHY;
} else if (healthyCount >= total / 2) {
status = HealthStatus.DEGRADED;
} else {
status = HealthStatus.UNHEALTHY;
}
return { status, serviceStatus, totalServices: total, healthyServices: healthyCount };
}
/**
* Check the health of a single instance. Returns the created HealthCheck record.
*/
export async function checkInstanceHealth(instanceId: string) {
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
if (!instance) {
throw new Error(`Instance ${instanceId} not found`);
}
if (instance.status !== InstanceStatus.RUNNING) {
throw new Error(`Instance ${instance.slug} is not running (status: ${instance.status})`);
}
const startTime = Date.now();
let containers: ContainerInfo[];
try {
containers = await docker.composePs(instance.basePath, instance.composeProject);
} catch (err) {
// If compose ps fails, record UNKNOWN status
const healthCheck = await prisma.healthCheck.create({
data: {
instanceId,
status: HealthStatus.UNKNOWN,
serviceStatus: {},
totalServices: 0,
healthyServices: 0,
responseTimeMs: Date.now() - startTime,
},
});
await prisma.instance.update({
where: { id: instanceId },
data: { lastHealthCheck: new Date() },
});
logger.warn(`[health] ${instance.slug}: compose ps failed: ${(err as Error).message}`);
return healthCheck;
}
const responseTimeMs = Date.now() - startTime;
const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers);
const healthCheck = await prisma.healthCheck.create({
data: {
instanceId,
status,
serviceStatus,
totalServices,
healthyServices,
responseTimeMs,
},
});
await prisma.instance.update({
where: { id: instanceId },
data: { lastHealthCheck: new Date() },
});
return healthCheck;
}
/**
* Check all running instances sequentially.
*/
export async function checkAllInstances(): Promise<void> {
const instances = await prisma.instance.findMany({
where: { status: InstanceStatus.RUNNING },
select: { id: true, slug: true },
});
if (instances.length === 0) {
logger.debug('[health] No running instances to check');
return;
}
let healthy = 0;
let degraded = 0;
let unhealthy = 0;
for (const inst of instances) {
try {
const check = await checkInstanceHealth(inst.id);
if (check.status === HealthStatus.HEALTHY) healthy++;
else if (check.status === HealthStatus.DEGRADED) degraded++;
else unhealthy++;
} catch (err) {
logger.warn(`[health] Failed to check ${inst.slug}: ${(err as Error).message}`);
unhealthy++;
}
}
logger.info(
`[health] Checked ${instances.length} instances: ${healthy} healthy, ${degraded} degraded, ${unhealthy} unhealthy`
);
}
/**
* Get paginated health history for an instance.
*/
export async function getHealthHistory(instanceId: string, page = 1, limit = 20) {
const [data, total] = await Promise.all([
prisma.healthCheck.findMany({
where: { instanceId },
orderBy: { checkedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.healthCheck.count({ where: { instanceId } }),
]);
return { data, total, page, limit };
}
/**
* Start the periodic health check scheduler.
*/
export function startHealthScheduler(intervalMs: number): NodeJS.Timeout | null {
if (intervalMs <= 0) {
logger.info('[health] Automated health checks disabled (interval=0)');
return null;
}
logger.info(`[health] Starting health scheduler (interval: ${intervalMs}ms)`);
// Run initial check after a short delay (let services start)
setTimeout(() => {
checkAllInstances().catch((err) =>
logger.error(`[health] Initial check failed: ${(err as Error).message}`)
);
}, 10_000);
return setInterval(() => {
checkAllInstances().catch((err) =>
logger.error(`[health] Scheduled check failed: ${(err as Error).message}`)
);
}, intervalMs);
}

View File

@@ -0,0 +1,94 @@
import { prisma } from '../lib/prisma';
import { env } from '../config/env';
import { AppError } from '../middleware/error-handler';
interface PortRangeConfig {
service: string;
start: number;
end: number;
}
function getPortRanges(): PortRangeConfig[] {
return [
{ service: 'api', start: env.PORT_RANGE_API_START, end: env.PORT_RANGE_API_END },
{ service: 'admin', start: env.PORT_RANGE_ADMIN_START, end: env.PORT_RANGE_ADMIN_END },
{ service: 'postgres', start: env.PORT_RANGE_POSTGRES_START, end: env.PORT_RANGE_POSTGRES_END },
{ service: 'nginx', start: env.PORT_RANGE_NGINX_START, end: env.PORT_RANGE_NGINX_END },
{ service: 'embed', start: env.PORT_RANGE_EMBED_START, end: env.PORT_RANGE_EMBED_END },
];
}
async function findNextAvailablePort(
start: number,
end: number,
tx?: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]
): Promise<number> {
const client = tx || prisma;
const allocated = await client.portAllocation.findMany({
where: { port: { gte: start, lte: end } },
select: { port: true },
orderBy: { port: 'asc' },
});
const usedPorts = new Set(allocated.map((a) => a.port));
for (let port = start; port <= end; port++) {
if (!usedPorts.has(port)) {
return port;
}
}
throw new AppError(503, `No available ports in range ${start}-${end}`, 'PORT_EXHAUSTED');
}
export interface AllocatedPorts {
config: Record<string, number>;
allocations: Array<{ port: number; service: string }>;
}
/**
* Allocate ports using a serializable transaction to prevent race conditions.
* Port allocation records are created immediately to act as a DB-level lock.
* They are linked to the instance later via instances.service.ts.
*/
export async function allocatePorts(): Promise<AllocatedPorts> {
return prisma.$transaction(async (tx) => {
const ranges = getPortRanges();
const config: Record<string, number> = {};
const allocations: Array<{ port: number; service: string }> = [];
for (const range of ranges) {
const port = await findNextAvailablePort(range.start, range.end, tx);
config[range.service] = port;
allocations.push({ port, service: range.service });
}
return { config, allocations };
});
}
export async function releasePorts(instanceId: string): Promise<void> {
await prisma.portAllocation.deleteMany({ where: { instanceId } });
}
export async function getPortUsage(): Promise<{
ranges: Array<{ service: string; start: number; end: number; used: number; total: number }>;
}> {
const ranges = getPortRanges();
const result = [];
for (const range of ranges) {
const used = await prisma.portAllocation.count({
where: { port: { gte: range.start, lte: range.end } },
});
result.push({
service: range.service,
start: range.start,
end: range.end,
used,
total: range.end - range.start + 1,
});
}
return { ranges: result };
}

View File

@@ -0,0 +1,73 @@
import crypto from 'crypto';
function randomHex(bytes = 32): string {
return crypto.randomBytes(bytes).toString('hex');
}
function randomPassword(length = 16): string {
// Generate password meeting CML policy: 12+ chars, uppercase, lowercase, digit
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lower = 'abcdefghijklmnopqrstuvwxyz';
const digits = '0123456789';
// Avoid $, #, !, % — they break Docker Compose .env files
// ($=var expansion, #=comment, !=bash history, %=printf)
const special = '@&*_-+=';
const all = upper + lower + digits + special;
// Ensure at least one of each required class
const required = [
upper[crypto.randomInt(upper.length)],
lower[crypto.randomInt(lower.length)],
digits[crypto.randomInt(digits.length)],
special[crypto.randomInt(special.length)],
];
// Fill remaining with random chars
const remaining = Array.from({ length: length - required.length }, () =>
all[crypto.randomInt(all.length)]
);
// Shuffle
const chars = [...required, ...remaining];
for (let i = chars.length - 1; i > 0; i--) {
const j = crypto.randomInt(i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
export interface InstanceSecrets {
postgresPassword: string;
redisPassword: string;
jwtAccessSecret: string;
jwtRefreshSecret: string;
encryptionKey: string;
initialAdminPassword: string;
nocodbAdminPassword: string;
grafanaAdminPassword: string;
listmonkAdminPassword: string;
listmonkApiToken: string;
giteaAdminPassword: string;
n8nEncryptionKey: string;
gancioAdminPassword: string;
}
export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEmail: string } {
return {
adminEmail,
postgresPassword: randomHex(16),
redisPassword: randomHex(16),
jwtAccessSecret: randomHex(32),
jwtRefreshSecret: randomHex(32),
encryptionKey: randomHex(32),
initialAdminPassword: randomPassword(16),
nocodbAdminPassword: randomPassword(16),
grafanaAdminPassword: randomPassword(16),
listmonkAdminPassword: randomPassword(16),
listmonkApiToken: randomHex(16),
giteaAdminPassword: randomPassword(16),
n8nEncryptionKey: randomHex(32),
gancioAdminPassword: randomPassword(16),
};
}

View File

@@ -0,0 +1,227 @@
import Handlebars from 'handlebars';
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../utils/logger';
// Register helpers
Handlebars.registerHelper('ifEq', function (this: unknown, a: unknown, b: unknown, options: Handlebars.HelperOptions) {
return a === b ? options.fn(this) : options.inverse(this);
});
Handlebars.registerHelper('unless', function (this: unknown, condition: unknown, options: Handlebars.HelperOptions) {
return !condition ? options.fn(this) : options.inverse(this);
});
Handlebars.registerHelper('now', function () {
return new Date().toISOString();
});
Handlebars.registerHelper('math', function (a: number, op: string, b: number) {
switch (op) {
case '+': return a + b;
case '-': return a - b;
default: return a;
}
});
export interface TemplateContext {
// Instance identity
slug: string;
name: string;
domain: string;
containerPrefix: string; // e.g., "cml-better-edmonton"
networkName: string; // e.g., "cml-better-edmonton"
composeProject: string; // e.g., "cml-better-edmonton"
// Ports
ports: {
api: number;
admin: number;
postgres: number;
nginx: number;
embed: number;
};
// Secrets (decrypted)
secrets: {
postgresPassword: string;
redisPassword: string;
jwtAccessSecret: string;
jwtRefreshSecret: string;
encryptionKey: string;
initialAdminPassword: string;
adminEmail: string;
nocodbAdminPassword: string;
grafanaAdminPassword: string;
listmonkAdminPassword: string;
listmonkApiToken: string;
giteaAdminPassword: string;
n8nEncryptionKey: string;
gancioAdminPassword: string;
};
// Feature flags
enableMedia: boolean;
enableChat: boolean;
enableGancio: boolean;
enableListmonk: boolean;
enableMonitoring: boolean;
enableDevTools: boolean;
enablePayments: boolean;
// SMTP
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpFrom: string;
emailTestMode: boolean;
// Git
gitBranch: string;
}
/** Subset of Instance fields needed to build a TemplateContext. */
export interface InstanceForTemplate {
slug: string;
name: string;
domain: string;
composeProject: string;
portConfig: unknown;
enableMedia: boolean;
enableChat: boolean;
enableGancio: boolean;
enableListmonk: boolean;
enableMonitoring: boolean;
enableDevTools: boolean;
enablePayments: boolean;
smtpHost: string | null;
smtpPort: number | null;
smtpUser: string | null;
smtpFrom: string | null;
emailTestMode: boolean;
gitBranch: string;
}
/**
* Build the TemplateContext from an instance record and its decrypted secrets.
*/
export function buildTemplateContext(
instance: InstanceForTemplate,
secrets: Record<string, string>
): TemplateContext {
const ports = instance.portConfig as Record<string, number>;
return {
slug: instance.slug,
name: instance.name,
domain: instance.domain,
containerPrefix: instance.composeProject,
networkName: instance.composeProject,
composeProject: instance.composeProject,
ports: {
api: ports.api,
admin: ports.admin,
postgres: ports.postgres,
nginx: ports.nginx,
embed: ports.embed,
},
secrets: {
postgresPassword: secrets.postgresPassword,
redisPassword: secrets.redisPassword,
jwtAccessSecret: secrets.jwtAccessSecret,
jwtRefreshSecret: secrets.jwtRefreshSecret,
encryptionKey: secrets.encryptionKey,
initialAdminPassword: secrets.initialAdminPassword,
adminEmail: secrets.adminEmail,
nocodbAdminPassword: secrets.nocodbAdminPassword,
grafanaAdminPassword: secrets.grafanaAdminPassword,
listmonkAdminPassword: secrets.listmonkAdminPassword,
listmonkApiToken: secrets.listmonkApiToken,
giteaAdminPassword: secrets.giteaAdminPassword,
n8nEncryptionKey: secrets.n8nEncryptionKey,
gancioAdminPassword: secrets.gancioAdminPassword,
},
enableMedia: instance.enableMedia,
enableChat: instance.enableChat,
enableGancio: instance.enableGancio,
enableListmonk: instance.enableListmonk,
enableMonitoring: instance.enableMonitoring,
enableDevTools: instance.enableDevTools,
enablePayments: instance.enablePayments,
smtpHost: instance.smtpHost || '',
smtpPort: instance.smtpPort || 587,
smtpUser: instance.smtpUser || '',
smtpFrom: instance.smtpFrom || '',
emailTestMode: instance.emailTestMode,
gitBranch: instance.gitBranch,
};
}
const templateCache = new Map<string, HandlebarsTemplateDelegate>();
async function loadTemplate(templatePath: string): Promise<HandlebarsTemplateDelegate> {
if (templateCache.has(templatePath)) {
return templateCache.get(templatePath)!;
}
const source = await fs.readFile(templatePath, 'utf-8');
const compiled = Handlebars.compile(source, { noEscape: true });
templateCache.set(templatePath, compiled);
return compiled;
}
export async function renderTemplate(templateName: string, context: TemplateContext): Promise<string> {
const templatesDir = path.resolve(__dirname, '../..', 'templates');
const templatePath = path.join(templatesDir, templateName);
const template = await loadTemplate(templatePath);
return template(context);
}
export async function renderAllTemplates(context: TemplateContext, outputDir: string): Promise<void> {
const templatesDir = path.resolve(__dirname, '../..', 'templates');
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' },
];
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);
const outputPath = path.join(outputDir, output);
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rendered, 'utf-8');
logger.debug(`Rendered ${template}${outputPath}`);
}
// Copy static files (nginx.conf doesn't need templating)
const staticFiles = ['nginx/nginx.conf'];
for (const file of staticFiles) {
const srcPath = path.join(templatesDir, file);
try {
await fs.access(srcPath);
const destPath = path.join(outputDir, file);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(srcPath, destPath);
} catch {
logger.warn(`Static file not found: ${file}, skipping`);
}
}
}
export function clearTemplateCache(): void {
templateCache.clear();
}

View File

@@ -0,0 +1,37 @@
import crypto from 'crypto';
import { env } from '../config/env';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const TAG_LENGTH = 16;
function getKey(): Buffer {
return Buffer.from(env.ENCRYPTION_KEY, 'hex').subarray(0, 32);
}
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// Format: iv:tag:ciphertext (all base64)
return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
}
export function decrypt(encryptedText: string): string {
const [ivB64, tagB64, ciphertextB64] = encryptedText.split(':');
const iv = Buffer.from(ivB64, 'base64');
const tag = Buffer.from(tagB64, 'base64');
const ciphertext = Buffer.from(ciphertextB64, 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}
export function encryptJson(data: Record<string, unknown>): string {
return encrypt(JSON.stringify(data));
}
export function decryptJson<T = Record<string, unknown>>(encryptedText: string): T {
return JSON.parse(decrypt(encryptedText)) as T;
}

View File

@@ -0,0 +1,13 @@
import winston from 'winston';
export const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
process.env.NODE_ENV === 'production'
? winston.format.json()
: winston.format.combine(winston.format.colorize(), winston.format.simple())
),
transports: [new winston.transports.Console()],
});

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@prisma/*": ["prisma/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}