Okay Wish I could say I know exactly. Will do better next time promise lol

This commit is contained in:
2026-02-26 17:47:04 -07:00
parent 2fa50b001c
commit 9e51aac570
727 changed files with 217309 additions and 5801 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "pangolin_endpoint" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AuditAction" ADD VALUE 'SECRETS_VIEWED';

View File

@@ -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;

View File

@@ -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

View File

@@ -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));

View File

@@ -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));

View File

@@ -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>;

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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 } });

View File

@@ -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'

View File

@@ -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}`,

View File

@@ -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 });
}

View File

@@ -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),
};
}

View File

@@ -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 = [