Okay Wish I could say I know exactly. Will do better next time promise lol
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "instances" ADD COLUMN "pangolin_endpoint" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AuditAction" ADD VALUE 'SECRETS_VIEWED';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "instances" ADD COLUMN "enable_meet" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "enable_people" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "enable_sms" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "enable_social" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "jvb_advertise_ip" TEXT;
|
||||
@@ -84,13 +84,19 @@ model Instance {
|
||||
enableMonitoring Boolean @default(false) @map("enable_monitoring")
|
||||
enableDevTools Boolean @default(false) @map("enable_dev_tools")
|
||||
enablePayments Boolean @default(false) @map("enable_payments")
|
||||
enableMeet Boolean @default(false) @map("enable_meet")
|
||||
enableSms Boolean @default(false) @map("enable_sms")
|
||||
enableSocial Boolean @default(false) @map("enable_social")
|
||||
enablePeople Boolean @default(false) @map("enable_people")
|
||||
jvbAdvertiseIp String? @map("jvb_advertise_ip")
|
||||
|
||||
// Admin config
|
||||
adminEmail String @map("admin_email")
|
||||
|
||||
// Pangolin tunnel
|
||||
pangolinSiteId String? @map("pangolin_site_id")
|
||||
pangolinNewtId String? @map("pangolin_newt_id")
|
||||
pangolinEndpoint String? @map("pangolin_endpoint")
|
||||
pangolinSiteId String? @map("pangolin_site_id")
|
||||
pangolinNewtId String? @map("pangolin_newt_id")
|
||||
pangolinNewtSecret String? @map("pangolin_newt_secret")
|
||||
|
||||
// SMTP
|
||||
@@ -192,6 +198,7 @@ enum AuditAction {
|
||||
INSTANCE_STOP
|
||||
INSTANCE_RESTART
|
||||
INSTANCE_UPGRADE
|
||||
SECRETS_VIEWED
|
||||
BACKUP_CREATE
|
||||
BACKUP_DELETE
|
||||
PANGOLIN_SETUP
|
||||
|
||||
@@ -12,7 +12,7 @@ router.use(authenticate);
|
||||
// ─── Cross-Instance Backup Endpoints ────────────────────────────────
|
||||
|
||||
// List all backups (cross-instance)
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), 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));
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema } from './instances.schemas';
|
||||
import * as instancesService from './instances.service';
|
||||
import * as healthService from '../../services/health.service';
|
||||
import * as backupService from '../../services/backup.service';
|
||||
@@ -68,7 +68,7 @@ router.post(
|
||||
|
||||
// ─── CRUD Endpoints ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
|
||||
const instances = await instancesService.listInstances();
|
||||
res.json({ data: instances });
|
||||
});
|
||||
@@ -84,7 +84,7 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
router.get('/:id', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
|
||||
const instance = await instancesService.getInstance(req.params.id as string);
|
||||
res.json({ data: instance });
|
||||
});
|
||||
@@ -136,12 +136,13 @@ router.get(
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
instanceId: req.params.id as string,
|
||||
action: AuditAction.INSTANCE_UPDATE,
|
||||
details: { type: 'secrets_viewed' },
|
||||
action: AuditAction.SECRETS_VIEWED,
|
||||
details: { instanceId: req.params.id as string },
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ data: secrets });
|
||||
}
|
||||
);
|
||||
@@ -163,6 +164,36 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Tunnel Management ──────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
'/:id/tunnel',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(configureTunnelSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.configureTunnel(
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
req.user!.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ data: result });
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id/tunnel',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.removeTunnel(
|
||||
req.params.id as string,
|
||||
req.user!.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ data: result });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
@@ -211,6 +242,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/:id/services',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const services = await instancesService.getInstanceServices(req.params.id as string);
|
||||
res.json({ data: services });
|
||||
@@ -246,6 +278,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/:id/health-history',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
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));
|
||||
@@ -267,6 +300,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/:id/backups',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
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));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createInstanceSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
name: z.string().min(2).max(100).regex(/^[a-zA-Z0-9 '_\-.]+$/, 'Name contains invalid characters'),
|
||||
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),
|
||||
domain: z.string().min(3).max(255).regex(/^[a-zA-Z0-9.\-]+$/, 'Domain must be a valid hostname'),
|
||||
adminEmail: z.string().email(),
|
||||
enableMedia: z.boolean().default(false),
|
||||
enableChat: z.boolean().default(false),
|
||||
@@ -12,17 +12,25 @@ export const createInstanceSchema = z.object({
|
||||
enableMonitoring: z.boolean().default(false),
|
||||
enableDevTools: z.boolean().default(false),
|
||||
enablePayments: z.boolean().default(false),
|
||||
smtpHost: z.string().optional(),
|
||||
enableMeet: z.boolean().default(false),
|
||||
enableSms: z.boolean().default(false),
|
||||
enableSocial: z.boolean().default(false),
|
||||
enablePeople: z.boolean().default(false),
|
||||
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).optional(),
|
||||
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
|
||||
smtpPort: z.coerce.number().optional(),
|
||||
smtpUser: z.string().optional(),
|
||||
smtpFrom: z.string().optional(),
|
||||
emailTestMode: z.boolean().default(true),
|
||||
enablePangolin: z.boolean().default(false),
|
||||
pangolinEndpoint: z.string().url().optional(),
|
||||
pangolinNewtId: z.string().optional(),
|
||||
pangolinNewtSecret: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateInstanceSchema = z.object({
|
||||
name: z.string().min(2).max(100).optional(),
|
||||
name: z.string().min(2).max(100).regex(/^[a-zA-Z0-9 '_\-.]+$/, 'Name contains invalid characters').optional(),
|
||||
enableMedia: z.boolean().optional(),
|
||||
enableChat: z.boolean().optional(),
|
||||
enableGancio: z.boolean().optional(),
|
||||
@@ -30,7 +38,12 @@ export const updateInstanceSchema = z.object({
|
||||
enableMonitoring: z.boolean().optional(),
|
||||
enableDevTools: z.boolean().optional(),
|
||||
enablePayments: z.boolean().optional(),
|
||||
smtpHost: z.string().optional(),
|
||||
enableMeet: z.boolean().optional(),
|
||||
enableSms: z.boolean().optional(),
|
||||
enableSocial: z.boolean().optional(),
|
||||
enablePeople: z.boolean().optional(),
|
||||
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).nullable().optional(),
|
||||
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
|
||||
smtpPort: z.coerce.number().optional(),
|
||||
smtpUser: z.string().optional(),
|
||||
smtpFrom: z.string().optional(),
|
||||
@@ -39,9 +52,9 @@ export const updateInstanceSchema = z.object({
|
||||
});
|
||||
|
||||
export const registerInstanceSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
name: z.string().min(2).max(100).regex(/^[a-zA-Z0-9 '_\-.]+$/, 'Name contains invalid characters'),
|
||||
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),
|
||||
domain: z.string().min(3).max(255).regex(/^[a-zA-Z0-9.\-]+$/, 'Domain must be a valid hostname'),
|
||||
basePath: z.string().min(1),
|
||||
composeProject: z.string().min(1),
|
||||
portConfig: z.object({
|
||||
@@ -58,6 +71,10 @@ export const registerInstanceSchema = z.object({
|
||||
enableMonitoring: z.boolean().default(false),
|
||||
enableDevTools: z.boolean().default(false),
|
||||
enablePayments: z.boolean().default(false),
|
||||
enableMeet: z.boolean().default(false),
|
||||
enableSms: z.boolean().default(false),
|
||||
enableSocial: z.boolean().default(false),
|
||||
enablePeople: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -69,6 +86,16 @@ export const reconfigureInstanceSchema = z.object({
|
||||
enableMonitoring: z.boolean().optional(),
|
||||
enableDevTools: z.boolean().optional(),
|
||||
enablePayments: z.boolean().optional(),
|
||||
enableMeet: z.boolean().optional(),
|
||||
enableSms: z.boolean().optional(),
|
||||
enableSocial: z.boolean().optional(),
|
||||
enablePeople: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const configureTunnelSchema = z.object({
|
||||
pangolinEndpoint: z.string().url(),
|
||||
pangolinNewtId: z.string().min(1),
|
||||
pangolinNewtSecret: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const importInstancesSchema = z.object({
|
||||
@@ -79,4 +106,5 @@ 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 ConfigureTunnelInput = z.infer<typeof configureTunnelSchema>;
|
||||
export type ImportInstancesInput = z.infer<typeof importInstancesSchema>;
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput, ConfigureTunnelInput } from './instances.schemas';
|
||||
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
|
||||
import { logger } from '../../utils/logger';
|
||||
import path from 'path';
|
||||
@@ -19,7 +19,7 @@ import path from 'path';
|
||||
export async function listInstances() {
|
||||
return prisma.instance.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
omit: { encryptedSecrets: true },
|
||||
omit: { encryptedSecrets: true, pangolinNewtSecret: true },
|
||||
include: {
|
||||
portAllocations: true,
|
||||
_count: { select: { healthChecks: true, backups: true } },
|
||||
@@ -30,7 +30,7 @@ export async function listInstances() {
|
||||
export async function getInstance(id: string) {
|
||||
const instance = await prisma.instance.findUnique({
|
||||
where: { id },
|
||||
omit: { encryptedSecrets: true },
|
||||
omit: { encryptedSecrets: true, pangolinNewtSecret: true },
|
||||
include: {
|
||||
portAllocations: true,
|
||||
healthChecks: { orderBy: { checkedAt: 'desc' }, take: 10 },
|
||||
@@ -82,7 +82,15 @@ export async function createInstance(input: CreateInstanceInput, userId: string,
|
||||
enableMonitoring: input.enableMonitoring,
|
||||
enableDevTools: input.enableDevTools,
|
||||
enablePayments: input.enablePayments,
|
||||
enableMeet: input.enableMeet,
|
||||
enableSms: input.enableSms,
|
||||
enableSocial: input.enableSocial,
|
||||
enablePeople: input.enablePeople,
|
||||
jvbAdvertiseIp: input.jvbAdvertiseIp,
|
||||
adminEmail: input.adminEmail,
|
||||
pangolinEndpoint: input.enablePangolin ? input.pangolinEndpoint : null,
|
||||
pangolinNewtId: input.enablePangolin ? input.pangolinNewtId : null,
|
||||
pangolinNewtSecret: input.enablePangolin ? input.pangolinNewtSecret : null,
|
||||
smtpHost: input.smtpHost,
|
||||
smtpPort: input.smtpPort,
|
||||
smtpUser: input.smtpUser,
|
||||
@@ -172,6 +180,10 @@ export async function registerInstance(input: RegisterInstanceInput, userId: str
|
||||
enableMonitoring: input.enableMonitoring,
|
||||
enableDevTools: input.enableDevTools,
|
||||
enablePayments: input.enablePayments,
|
||||
enableMeet: input.enableMeet,
|
||||
enableSms: input.enableSms,
|
||||
enableSocial: input.enableSocial,
|
||||
enablePeople: input.enablePeople,
|
||||
adminEmail: input.adminEmail,
|
||||
notes: input.notes,
|
||||
},
|
||||
@@ -605,3 +617,161 @@ export async function reconfigureInstance(
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ─── Tunnel Management ──────────────────────────────────────────────
|
||||
|
||||
export async function configureTunnel(
|
||||
id: string,
|
||||
input: ConfigureTunnelInput,
|
||||
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 configure tunnel on an external instance', 'NOT_MANAGED');
|
||||
}
|
||||
if (!instance.encryptedSecrets) {
|
||||
throw new AppError(400, 'Instance has no secrets — cannot configure tunnel', 'NOT_MANAGED');
|
||||
}
|
||||
if (instance.status !== 'RUNNING' && instance.status !== 'STOPPED') {
|
||||
throw new AppError(400, `Cannot configure tunnel in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
// If secret not provided, keep existing; throw if both are null (first-time setup)
|
||||
const newtSecret = input.pangolinNewtSecret || instance.pangolinNewtSecret;
|
||||
if (!newtSecret) {
|
||||
throw new AppError(400, 'Newt secret is required for first-time tunnel setup', 'VALIDATION_ERROR');
|
||||
}
|
||||
|
||||
// Update DB
|
||||
const updated = await prisma.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
pangolinEndpoint: input.pangolinEndpoint,
|
||||
pangolinNewtId: input.pangolinNewtId,
|
||||
pangolinNewtSecret: newtSecret,
|
||||
statusMessage: 'Configuring tunnel...',
|
||||
},
|
||||
});
|
||||
|
||||
// Re-render templates
|
||||
clearTemplateCache();
|
||||
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
|
||||
const context = buildTemplateContext(updated, secrets);
|
||||
await renderAllTemplates(context, instance.basePath);
|
||||
|
||||
// If running, bring up the newt container
|
||||
if (instance.status === 'RUNNING') {
|
||||
try {
|
||||
await docker.composeUp(instance.basePath, instance.composeProject, ['newt']);
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: 'Tunnel configured and Newt started' },
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: `Tunnel configured but Newt failed: ${errorMsg}` },
|
||||
});
|
||||
throw new AppError(500, `Tunnel configured but Newt failed to start: ${errorMsg}`, 'DOCKER_ERROR');
|
||||
}
|
||||
} else {
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: 'Tunnel configured — start instance to apply' },
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.PANGOLIN_SETUP,
|
||||
details: {
|
||||
event: 'tunnel_configure',
|
||||
endpoint: input.pangolinEndpoint,
|
||||
newtId: input.pangolinNewtId,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function removeTunnel(
|
||||
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.isRegistered) {
|
||||
throw new AppError(400, 'Cannot modify tunnel on an external instance', 'NOT_MANAGED');
|
||||
}
|
||||
if (!instance.encryptedSecrets) {
|
||||
throw new AppError(400, 'Instance has no secrets — cannot modify tunnel', 'NOT_MANAGED');
|
||||
}
|
||||
if (instance.status !== 'RUNNING' && instance.status !== 'STOPPED') {
|
||||
throw new AppError(400, `Cannot remove tunnel in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
// Null out all Pangolin fields
|
||||
const updated = await prisma.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
pangolinEndpoint: null,
|
||||
pangolinNewtId: null,
|
||||
pangolinNewtSecret: null,
|
||||
statusMessage: 'Removing tunnel...',
|
||||
},
|
||||
});
|
||||
|
||||
// Re-render templates (enablePangolin will compute to false, removing newt block)
|
||||
clearTemplateCache();
|
||||
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
|
||||
const context = buildTemplateContext(updated, secrets);
|
||||
await renderAllTemplates(context, instance.basePath);
|
||||
|
||||
// If running, full compose up with --remove-orphans removes the orphaned newt container
|
||||
if (instance.status === 'RUNNING') {
|
||||
try {
|
||||
await docker.composeUp(instance.basePath, instance.composeProject);
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: 'Tunnel removed' },
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: `Tunnel removed but Docker update failed: ${errorMsg}` },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: 'Tunnel removed — start instance to apply' },
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.PANGOLIN_SETUP,
|
||||
details: { event: 'tunnel_remove' } as unknown as Prisma.InputJsonValue,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { InstanceStatus, AuditAction } from '@prisma/client';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { execFile as execFileCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
@@ -9,7 +9,7 @@ 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);
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
/**
|
||||
* Directories/files to exclude when copying CML source to instance directory.
|
||||
@@ -73,11 +73,8 @@ export async function provision(instanceId: string): Promise<void> {
|
||||
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 }
|
||||
);
|
||||
const rsyncArgs = ['-a', ...COPY_EXCLUDES.flatMap((e) => ['--exclude', e]), `${env.CML_SOURCE_PATH}/`, `${basePath}/`];
|
||||
await execFile('rsync', rsyncArgs, { timeout: 120_000 });
|
||||
|
||||
// ── Step 2b: Create media directories ──────────────────────────
|
||||
// Media API volume mounts use ./media as the read-only base with
|
||||
|
||||
@@ -6,7 +6,7 @@ const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
|
||||
const settings = await prisma.ccpSetting.findMany();
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const s of settings) {
|
||||
|
||||
@@ -40,7 +40,17 @@ const authLimiter = rateLimit({
|
||||
message: { error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } },
|
||||
});
|
||||
|
||||
// Global API rate limiter — safety net against resource exhaustion
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 300, // 300 req/min per IP (generous for a control panel)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: { message: 'Too many requests, please try again later', code: 'RATE_LIMITED' } },
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/instances', instanceRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Prisma, BackupStatus, AuditAction, InstanceStatus } from '@prisma/clien
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { execFile as execFileCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { env } from '../config/env';
|
||||
@@ -10,7 +10,18 @@ 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);
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
/**
|
||||
* Validate that a path is within the allowed storage boundary.
|
||||
*/
|
||||
function assertPathWithinBoundary(filePath: string, boundary: string, label: string): void {
|
||||
const normalized = path.resolve(filePath);
|
||||
const normalizedBoundary = path.resolve(boundary);
|
||||
if (!normalized.startsWith(normalizedBoundary + path.sep)) {
|
||||
throw new AppError(403, `${label} path outside allowed directory`, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file.
|
||||
@@ -103,8 +114,8 @@ async function performBackup(
|
||||
const dumpPath = path.join(backupDir, 'v2-postgres.sql');
|
||||
await fs.writeFile(dumpPath, dumpOutput);
|
||||
|
||||
// Gzip the dump
|
||||
await exec(`gzip "${dumpPath}"`, { timeout: 120_000 });
|
||||
// Gzip the dump (execFile avoids shell injection)
|
||||
await execFile('gzip', [dumpPath], { timeout: 120_000 });
|
||||
const gzPath = dumpPath + '.gz';
|
||||
|
||||
manifestFiles.push({
|
||||
@@ -124,7 +135,7 @@ async function performBackup(
|
||||
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 });
|
||||
await execFile('tar', ['-czf', uploadsArchive, '-C', instance.basePath, 'uploads'], { timeout: 300_000 });
|
||||
|
||||
manifestFiles.push({
|
||||
name: 'uploads.tar.gz',
|
||||
@@ -153,7 +164,7 @@ async function performBackup(
|
||||
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)}"`, {
|
||||
await execFile('tar', ['-czf', archivePath, '-C', path.dirname(backupDir), path.basename(backupDir)], {
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
@@ -222,8 +233,9 @@ export async function deleteBackup(backupId: string, userId?: string, ipAddress?
|
||||
throw new AppError(404, 'Backup not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Delete archive file
|
||||
// Delete archive file (validate path boundary to prevent arbitrary file deletion)
|
||||
if (backup.archivePath) {
|
||||
assertPathWithinBoundary(backup.archivePath, env.BACKUP_STORAGE_PATH, 'Backup archive');
|
||||
try {
|
||||
await fs.unlink(backup.archivePath);
|
||||
} catch {
|
||||
@@ -296,6 +308,7 @@ export async function cleanupOldBackups(retentionDays: number): Promise<number>
|
||||
for (const backup of oldBackups) {
|
||||
try {
|
||||
if (backup.archivePath) {
|
||||
assertPathWithinBoundary(backup.archivePath, env.BACKUP_STORAGE_PATH, 'Backup archive');
|
||||
await fs.unlink(backup.archivePath);
|
||||
}
|
||||
await prisma.backup.delete({ where: { id: backup.id } });
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface DiscoveredInstance {
|
||||
enableMonitoring: boolean;
|
||||
enableDevTools: boolean;
|
||||
enablePayments: boolean;
|
||||
enableMeet: boolean;
|
||||
enableSms: boolean;
|
||||
enableSocial: boolean;
|
||||
enablePeople: boolean;
|
||||
emailTestMode: boolean;
|
||||
// Discovery metadata (UI-only, not persisted)
|
||||
source: 'parent' | 'docker';
|
||||
@@ -90,6 +94,10 @@ function extractFeatureFlags(envVars: Record<string, string>) {
|
||||
enableGancio: isTrue(envVars.GANCIO_SYNC_ENABLED),
|
||||
enableListmonk: isTrue(envVars.LISTMONK_SYNC_ENABLED),
|
||||
enablePayments: isTrue(envVars.ENABLE_PAYMENTS),
|
||||
enableMeet: isTrue(envVars.ENABLE_MEET),
|
||||
enableSms: isTrue(envVars.ENABLE_SMS),
|
||||
enableSocial: isTrue(envVars.ENABLE_SOCIAL),
|
||||
enablePeople: isTrue(envVars.ENABLE_PEOPLE),
|
||||
emailTestMode: isTrue(envVars.EMAIL_TEST_MODE),
|
||||
};
|
||||
}
|
||||
@@ -373,6 +381,10 @@ export async function autoDiscoverOnStartup(): Promise<void> {
|
||||
enableMonitoring: inst.enableMonitoring,
|
||||
enableDevTools: inst.enableDevTools,
|
||||
enablePayments: inst.enablePayments,
|
||||
enableMeet: inst.enableMeet,
|
||||
enableSms: inst.enableSms,
|
||||
enableSocial: inst.enableSocial,
|
||||
enablePeople: inst.enablePeople,
|
||||
},
|
||||
userId,
|
||||
'auto-discovery'
|
||||
|
||||
@@ -223,7 +223,7 @@ export async function composeExec(
|
||||
envVars?: Record<string, string>
|
||||
): Promise<string> {
|
||||
const envFlags = envVars
|
||||
? Object.entries(envVars).map(([k, v]) => `-e ${k}=${v}`).join(' ') + ' '
|
||||
? Object.entries(envVars).map(([k, v]) => `-e ${k}='${v.replace(/'/g, "'\\''")}'`).join(' ') + ' '
|
||||
: '';
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`,
|
||||
|
||||
@@ -6,22 +6,28 @@ interface PortRangeConfig {
|
||||
service: string;
|
||||
start: number;
|
||||
end: number;
|
||||
blockSize?: number;
|
||||
}
|
||||
|
||||
// Nginx embed proxies use a block of consecutive ports (embed+0 through embed+14).
|
||||
// Allocate in blocks of 16 to prevent overlapping port ranges between instances.
|
||||
const EMBED_BLOCK_SIZE = 20;
|
||||
|
||||
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 },
|
||||
{ service: 'embed', start: env.PORT_RANGE_EMBED_START, end: env.PORT_RANGE_EMBED_END, blockSize: EMBED_BLOCK_SIZE },
|
||||
];
|
||||
}
|
||||
|
||||
async function findNextAvailablePort(
|
||||
start: number,
|
||||
end: number,
|
||||
tx?: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]
|
||||
tx?: Parameters<Parameters<typeof prisma.$transaction>[0]>[0],
|
||||
blockSize: number = 1
|
||||
): Promise<number> {
|
||||
const client = tx || prisma;
|
||||
const allocated = await client.portAllocation.findMany({
|
||||
@@ -32,7 +38,7 @@ async function findNextAvailablePort(
|
||||
|
||||
const usedPorts = new Set(allocated.map((a) => a.port));
|
||||
|
||||
for (let port = start; port <= end; port++) {
|
||||
for (let port = start; port + blockSize - 1 <= end; port += blockSize) {
|
||||
if (!usedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
@@ -58,7 +64,7 @@ export async function allocatePorts(): Promise<AllocatedPorts> {
|
||||
const allocations: Array<{ port: number; service: string }> = [];
|
||||
|
||||
for (const range of ranges) {
|
||||
const port = await findNextAvailablePort(range.start, range.end, tx);
|
||||
const port = await findNextAvailablePort(range.start, range.end, tx, range.blockSize ?? 1);
|
||||
config[range.service] = port;
|
||||
allocations.push({ port, service: range.service });
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ function randomPassword(length = 16): string {
|
||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const digits = '0123456789';
|
||||
// Avoid $, #, !, % — they break Docker Compose .env files
|
||||
// ($=var expansion, #=comment, !=bash history, %=printf)
|
||||
const special = '@&*_-+=';
|
||||
// Avoid chars that break Docker Compose .env or YAML:
|
||||
// $=var expansion, #=comment, !=bash history, %=printf,
|
||||
// &=YAML anchor, *=YAML alias, :=YAML key-value, []=YAML flow
|
||||
const special = '@_-+=';
|
||||
const all = upper + lower + digits + special;
|
||||
|
||||
// Ensure at least one of each required class
|
||||
@@ -51,6 +52,11 @@ export interface InstanceSecrets {
|
||||
giteaAdminPassword: string;
|
||||
n8nEncryptionKey: string;
|
||||
gancioAdminPassword: string;
|
||||
vaultwardenAdminToken: string;
|
||||
jitsiAppSecret: string;
|
||||
jitsiJicofoAuthPassword: string;
|
||||
jitsiJvbAuthPassword: string;
|
||||
rocketchatAdminPassword: string;
|
||||
}
|
||||
|
||||
export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEmail: string } {
|
||||
@@ -69,5 +75,10 @@ export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEm
|
||||
giteaAdminPassword: randomPassword(16),
|
||||
n8nEncryptionKey: randomHex(32),
|
||||
gancioAdminPassword: randomPassword(16),
|
||||
vaultwardenAdminToken: randomHex(32),
|
||||
jitsiAppSecret: randomHex(32),
|
||||
jitsiJicofoAuthPassword: randomHex(16),
|
||||
jitsiJvbAuthPassword: randomHex(16),
|
||||
rocketchatAdminPassword: randomPassword(16),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface TemplateContext {
|
||||
giteaAdminPassword: string;
|
||||
n8nEncryptionKey: string;
|
||||
gancioAdminPassword: string;
|
||||
vaultwardenAdminToken: string;
|
||||
jitsiAppSecret: string;
|
||||
jitsiJicofoAuthPassword: string;
|
||||
jitsiJvbAuthPassword: string;
|
||||
rocketchatAdminPassword: string;
|
||||
};
|
||||
|
||||
// Feature flags
|
||||
@@ -68,6 +73,19 @@ export interface TemplateContext {
|
||||
enableMonitoring: boolean;
|
||||
enableDevTools: boolean;
|
||||
enablePayments: boolean;
|
||||
enableMeet: boolean;
|
||||
enableSms: boolean;
|
||||
enableSocial: boolean;
|
||||
enablePeople: boolean;
|
||||
jvbAdvertiseIp: string;
|
||||
enablePangolin: boolean;
|
||||
|
||||
// Pangolin tunnel
|
||||
pangolin: {
|
||||
endpoint: string;
|
||||
newtId: string;
|
||||
newtSecret: string;
|
||||
};
|
||||
|
||||
// SMTP
|
||||
smtpHost: string;
|
||||
@@ -94,6 +112,14 @@ export interface InstanceForTemplate {
|
||||
enableMonitoring: boolean;
|
||||
enableDevTools: boolean;
|
||||
enablePayments: boolean;
|
||||
enableMeet: boolean;
|
||||
enableSms: boolean;
|
||||
enableSocial: boolean;
|
||||
enablePeople: boolean;
|
||||
jvbAdvertiseIp: string | null;
|
||||
pangolinEndpoint: string | null;
|
||||
pangolinNewtId: string | null;
|
||||
pangolinNewtSecret: string | null;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number | null;
|
||||
smtpUser: string | null;
|
||||
@@ -139,6 +165,11 @@ export function buildTemplateContext(
|
||||
giteaAdminPassword: secrets.giteaAdminPassword,
|
||||
n8nEncryptionKey: secrets.n8nEncryptionKey,
|
||||
gancioAdminPassword: secrets.gancioAdminPassword,
|
||||
vaultwardenAdminToken: secrets.vaultwardenAdminToken,
|
||||
jitsiAppSecret: secrets.jitsiAppSecret || '',
|
||||
jitsiJicofoAuthPassword: secrets.jitsiJicofoAuthPassword || '',
|
||||
jitsiJvbAuthPassword: secrets.jitsiJvbAuthPassword || '',
|
||||
rocketchatAdminPassword: secrets.rocketchatAdminPassword || '',
|
||||
},
|
||||
enableMedia: instance.enableMedia,
|
||||
enableChat: instance.enableChat,
|
||||
@@ -147,6 +178,17 @@ export function buildTemplateContext(
|
||||
enableMonitoring: instance.enableMonitoring,
|
||||
enableDevTools: instance.enableDevTools,
|
||||
enablePayments: instance.enablePayments,
|
||||
enableMeet: instance.enableMeet,
|
||||
enableSms: instance.enableSms,
|
||||
enableSocial: instance.enableSocial,
|
||||
enablePeople: instance.enablePeople,
|
||||
jvbAdvertiseIp: instance.jvbAdvertiseIp || '',
|
||||
enablePangolin: !!(instance.pangolinEndpoint && instance.pangolinNewtId && instance.pangolinNewtSecret),
|
||||
pangolin: {
|
||||
endpoint: instance.pangolinEndpoint || '',
|
||||
newtId: instance.pangolinNewtId || '',
|
||||
newtSecret: instance.pangolinNewtSecret || '',
|
||||
},
|
||||
smtpHost: instance.smtpHost || '',
|
||||
smtpPort: instance.smtpPort || 587,
|
||||
smtpUser: instance.smtpUser || '',
|
||||
@@ -177,6 +219,10 @@ export async function renderTemplate(templateName: string, context: TemplateCont
|
||||
}
|
||||
|
||||
export async function renderAllTemplates(context: TemplateContext, outputDir: string): Promise<void> {
|
||||
// Always read fresh templates from disk — provisioning is infrequent and
|
||||
// templates may have changed since the API last cached them.
|
||||
clearTemplateCache();
|
||||
|
||||
const templatesDir = path.resolve(__dirname, '../..', 'templates');
|
||||
|
||||
const templateFiles = [
|
||||
|
||||
Reference in New Issue
Block a user