Add remote instance management with mTLS agent and phone-home registration
Enables the CCP to manage CML instances on remote servers via a lightweight HTTP agent. Key components: - ExecutionDriver abstraction (local-driver.ts / remote-driver.ts) routes operations to local Docker or remote agent transparently - Remote agent package (agent/) with mTLS authentication, Docker Compose operations, file management, backup/upgrade delegation - Certificate service using openssl CLI for CA management and cert issuance - Phone-home registration: remote agents register via invite code, CCP admin approves, agent receives mTLS cert bundle automatically - config.sh integration with configure_control_panel() section - ccp-agent Docker Compose service (profile-gated) - Frontend: AgentRegistrationsPage, InviteCodesPage, Remote Agents sidebar menu - Security hardened: cert bundle wiped after delivery, shell injection prevention via execFile, command allowlist with metachar rejection, rate-limited public endpoints, auto-populated fingerprint pinning Also wires ENABLE_SOCIAL/PEOPLE/ANALYTICS through env.ts, seed.ts, and docker-compose env passthrough (from previous session). Bunker Admin
This commit is contained in:
43
changemaker-control-panel/agent/src/config/env.ts
Normal file
43
changemaker-control-panel/agent/src/config/env.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dotenv/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
// Agent server
|
||||
AGENT_PORT: z.coerce.number().default(7443),
|
||||
AGENT_LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// TLS certificates (required once approved)
|
||||
AGENT_CERT_PATH: z.string().default('/etc/ccp-agent/agent.pem'),
|
||||
AGENT_KEY_PATH: z.string().default('/etc/ccp-agent/agent.key'),
|
||||
AGENT_CA_CERT_PATH: z.string().default('/etc/ccp-agent/ca.pem'),
|
||||
|
||||
// Allowed CCP fingerprints (comma-separated SHA-256 hex)
|
||||
ALLOWED_CCP_FINGERPRINTS: z.string().default(''),
|
||||
|
||||
// Data directory (registry.json lives here)
|
||||
AGENT_DATA_DIR: z.string().default('/var/lib/ccp-agent'),
|
||||
|
||||
// Phone-home registration (set during initial setup, cleared after approval)
|
||||
CCP_URL: z.string().default(''),
|
||||
CCP_INVITE_CODE: z.string().default(''),
|
||||
CCP_AGENT_URL: z.string().default(''), // How CCP can reach this agent
|
||||
|
||||
// Instance info (for phone-home registration)
|
||||
INSTANCE_SLUG: z.string().default(''),
|
||||
INSTANCE_DOMAIN: z.string().default(''),
|
||||
INSTANCE_BASE_PATH: z.string().default(''),
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class AgentError extends Error {
|
||||
constructor(public statusCode: number, message: string, public code?: string) {
|
||||
super(message);
|
||||
this.name = 'AgentError';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
|
||||
if (err instanceof AgentError) {
|
||||
res.status(err.statusCode).json({
|
||||
error: err.code || 'AGENT_ERROR',
|
||||
message: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(`Unhandled error: ${err.message}`);
|
||||
res.status(500).json({
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'An internal error occurred',
|
||||
});
|
||||
}
|
||||
71
changemaker-control-panel/agent/src/middleware/mtls-auth.ts
Normal file
71
changemaker-control-panel/agent/src/middleware/mtls-auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { TLSSocket } from 'tls';
|
||||
|
||||
/**
|
||||
* Load allowed fingerprints from env var or from the auto-generated config file.
|
||||
* The config file is written during phone-home cert installation.
|
||||
*/
|
||||
function loadAllowedFingerprints(): string[] {
|
||||
// First check env var
|
||||
if (env.ALLOWED_CCP_FINGERPRINTS) {
|
||||
return env.ALLOWED_CCP_FINGERPRINTS.split(',').map((f) => f.trim().toLowerCase());
|
||||
}
|
||||
|
||||
// Fall back to the auto-generated fingerprint file from phone-home registration
|
||||
try {
|
||||
const configPath = path.join(env.AGENT_DATA_DIR, 'ccp-fingerprint');
|
||||
const fingerprint = fs.readFileSync(configPath, 'utf-8').trim().toLowerCase();
|
||||
if (fingerprint) return [fingerprint];
|
||||
} catch {
|
||||
// No fingerprint file — fingerprint pinning not available
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Cache the fingerprints at startup — reload requires restart
|
||||
const allowedFingerprints = loadAllowedFingerprints();
|
||||
|
||||
/**
|
||||
* mTLS authentication middleware.
|
||||
* Verifies that the connecting client presented a valid certificate
|
||||
* signed by the trusted CA, and checks against allowed fingerprints.
|
||||
*/
|
||||
export function mtlsAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const socket = req.socket as TLSSocket;
|
||||
|
||||
// Check that the client presented a certificate and it was authorized by the TLS layer
|
||||
if (!socket.authorized) {
|
||||
const authError = socket.authorizationError;
|
||||
logger.warn(`[mtls] Client certificate rejected: ${authError}`);
|
||||
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid client certificate' });
|
||||
return;
|
||||
}
|
||||
|
||||
const peerCert = socket.getPeerCertificate();
|
||||
if (!peerCert || !peerCert.raw) {
|
||||
logger.warn('[mtls] No peer certificate presented');
|
||||
res.status(401).json({ error: 'UNAUTHORIZED', message: 'No client certificate' });
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Check fingerprint against allowed list (env var or auto-generated file)
|
||||
if (allowedFingerprints.length > 0) {
|
||||
const fingerprint = crypto.createHash('sha256').update(peerCert.raw).digest('hex');
|
||||
if (!allowedFingerprints.includes(fingerprint)) {
|
||||
logger.warn(`[mtls] Client fingerprint ${fingerprint.substring(0, 16)}... not in allowed list`);
|
||||
res.status(403).json({ error: 'FORBIDDEN', message: 'Client certificate not authorized' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No fingerprint pinning configured — log a warning but allow (CA validation is still enforced)
|
||||
logger.warn('[mtls] No fingerprint pinning configured — relying on CA chain validation only');
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
105
changemaker-control-panel/agent/src/routes/backup.routes.ts
Normal file
105
changemaker-control-panel/agent/src/routes/backup.routes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { param } from '../utils/params';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as docker from '../services/docker.service';
|
||||
import { getSlugEntry } from '../services/registry.service';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
const router = Router();
|
||||
|
||||
// POST /instance/:slug/backup — Run pg_dump + tar uploads → return backup info
|
||||
router.post('/instance/:slug/backup', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupDir = path.join(env.AGENT_DATA_DIR, 'backups', param(req, 'slug'), timestamp);
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
const { pgPassword } = req.body;
|
||||
|
||||
try {
|
||||
// 1. pg_dump
|
||||
const dumpFile = path.join(backupDir, 'database.sql');
|
||||
const dump = await docker.composeExec(
|
||||
entry.basePath, entry.composeProject,
|
||||
'v2-postgres',
|
||||
'pg_dump -U changemaker -d changemaker',
|
||||
300_000,
|
||||
pgPassword ? { PGPASSWORD: pgPassword } : undefined
|
||||
);
|
||||
await fs.writeFile(dumpFile, dump, 'utf-8');
|
||||
|
||||
// Gzip the dump
|
||||
await exec(`gzip '${dumpFile}'`, { timeout: 120_000 });
|
||||
|
||||
// 2. Tar uploads if exists
|
||||
const uploadsDir = path.join(entry.basePath, 'uploads');
|
||||
let hasUploads = false;
|
||||
try {
|
||||
await fs.access(uploadsDir);
|
||||
hasUploads = true;
|
||||
} catch { /* no uploads dir */ }
|
||||
|
||||
if (hasUploads) {
|
||||
await exec(
|
||||
`tar -czf '${path.join(backupDir, 'uploads.tar.gz')}' -C '${entry.basePath}' uploads`,
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Create final archive
|
||||
const archiveName = `backup-${param(req, 'slug')}-${timestamp}.tar.gz`;
|
||||
const archivePath = path.join(env.AGENT_DATA_DIR, 'backups', archiveName);
|
||||
await exec(
|
||||
`tar -czf '${archivePath}' -C '${path.dirname(backupDir)}' '${timestamp}'`,
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
|
||||
// Clean up temp dir
|
||||
await fs.rm(backupDir, { recursive: true, force: true });
|
||||
|
||||
const stats = await fs.stat(archivePath);
|
||||
const backupId = timestamp;
|
||||
|
||||
logger.info(`[backup] Created backup for ${param(req, 'slug')}: ${archivePath} (${stats.size} bytes)`);
|
||||
|
||||
res.json({
|
||||
backupId,
|
||||
archivePath,
|
||||
sizeBytes: stats.size,
|
||||
timestamp,
|
||||
});
|
||||
} catch (err) {
|
||||
// Clean up on failure
|
||||
try { await fs.rm(backupDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// GET /instance/:slug/backup/:id/download — Stream backup archive
|
||||
router.get('/instance/:slug/backup/:id/download', async (req: Request, res: Response) => {
|
||||
const archiveName = `backup-${param(req, 'slug')}-${param(req, 'id')}.tar.gz`;
|
||||
const archivePath = path.join(env.AGENT_DATA_DIR, 'backups', archiveName);
|
||||
|
||||
try {
|
||||
await fs.access(archivePath);
|
||||
} catch {
|
||||
res.status(404).json({ error: 'NOT_FOUND', message: 'Backup archive not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await fs.stat(archivePath);
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Length', stats.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||
|
||||
const { createReadStream } = await import('fs');
|
||||
const stream = createReadStream(archivePath);
|
||||
stream.pipe(res);
|
||||
});
|
||||
|
||||
export default router;
|
||||
103
changemaker-control-panel/agent/src/routes/compose.routes.ts
Normal file
103
changemaker-control-panel/agent/src/routes/compose.routes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as docker from '../services/docker.service';
|
||||
import { getSlugEntry } from '../services/registry.service';
|
||||
import { param } from '../utils/params';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /instance/:slug/ps
|
||||
router.get('/instance/:slug/ps', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const containers = await docker.composePs(entry.basePath, entry.composeProject);
|
||||
res.json(containers);
|
||||
});
|
||||
|
||||
// GET /instance/:slug/logs
|
||||
router.get('/instance/:slug/logs', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const service = req.query.service as string | undefined;
|
||||
const tail = req.query.tail ? Number(req.query.tail) : 200;
|
||||
const since = req.query.since as string | undefined;
|
||||
const logs = await docker.composeLogs(entry.basePath, entry.composeProject, service, tail, since);
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/up
|
||||
router.post('/instance/:slug/up', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const services = req.body?.services as string[] | undefined;
|
||||
const result = await docker.composeUp(entry.basePath, entry.composeProject, services);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/stop
|
||||
router.post('/instance/:slug/stop', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const result = await docker.composeStop(entry.basePath, entry.composeProject);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/restart
|
||||
router.post('/instance/:slug/restart', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const service = req.body?.service as string | undefined;
|
||||
const result = await docker.composeRestart(entry.basePath, entry.composeProject, service);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/down
|
||||
router.post('/instance/:slug/down', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const removeVolumes = req.body?.removeVolumes === true;
|
||||
const result = await docker.composeDown(entry.basePath, entry.composeProject, removeVolumes);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/pull
|
||||
router.post('/instance/:slug/pull', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const result = await docker.composePull(entry.basePath, entry.composeProject);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/build
|
||||
router.post('/instance/:slug/build', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const result = await docker.composeBuild(entry.basePath, entry.composeProject);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/exec
|
||||
router.post('/instance/:slug/exec', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const { service, command, envVars } = req.body;
|
||||
if (!service || !command) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'service and command are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Reject shell metacharacters entirely — prevents `;`, `&&`, `|`, `$()`, backticks
|
||||
const SHELL_META = /[;&|`$(){}!><\n\r]/;
|
||||
if (SHELL_META.test(command)) {
|
||||
res.status(403).json({ error: 'FORBIDDEN', message: 'Command contains disallowed characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Command allowlist — only allow known safe command prefixes
|
||||
const allowedPatterns = [
|
||||
/^pg_dump\b/,
|
||||
/^npx\s+prisma\b/,
|
||||
/^cat\s/,
|
||||
/^ls\b/,
|
||||
/^echo\b/,
|
||||
];
|
||||
if (!allowedPatterns.some((p) => p.test(command))) {
|
||||
res.status(403).json({ error: 'FORBIDDEN', message: 'Command not in allowlist' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await docker.composeExec(entry.basePath, entry.composeProject, service, command, undefined, envVars);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
51
changemaker-control-panel/agent/src/routes/files.routes.ts
Normal file
51
changemaker-control-panel/agent/src/routes/files.routes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getSlugEntry } from '../services/registry.service';
|
||||
import { param } from '../utils/params';
|
||||
import * as fileService from '../services/file.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /instance/:slug/env — Read .env as key/value map
|
||||
router.get('/instance/:slug/env', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const envVars = await fileService.readEnvFile(entry.basePath);
|
||||
res.json(envVars);
|
||||
});
|
||||
|
||||
// POST /instance/:slug/files — Write rendered template files
|
||||
router.post('/instance/:slug/files', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const { files } = req.body;
|
||||
if (!Array.isArray(files)) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'files array required' });
|
||||
return;
|
||||
}
|
||||
await fileService.writeFiles(entry.basePath, files);
|
||||
res.json({ written: files.length });
|
||||
});
|
||||
|
||||
// POST /instance/:slug/mkdir — Create directory
|
||||
router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const { path: dirPath } = req.body;
|
||||
if (!dirPath) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'path required' });
|
||||
return;
|
||||
}
|
||||
await fileService.mkdirp(entry.basePath, dirPath);
|
||||
res.json({ created: dirPath });
|
||||
});
|
||||
|
||||
// POST /instance/:slug/clone-source — Git clone CML source
|
||||
router.post('/instance/:slug/clone-source', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const { gitRepo, gitBranch, excludes } = req.body;
|
||||
if (!gitRepo || !gitBranch) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'gitRepo and gitBranch required' });
|
||||
return;
|
||||
}
|
||||
await fileService.cloneSource(entry.basePath, gitRepo, gitBranch, excludes);
|
||||
res.json({ cloned: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
changemaker-control-panel/agent/src/routes/health.routes.ts
Normal file
15
changemaker-control-panel/agent/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
const startedAt = Date.now();
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
version: VERSION,
|
||||
uptime: Math.floor((Date.now() - startedAt) / 1000),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { param } from '../utils/params';
|
||||
import { registerSlug, unregisterSlug, listSlugs } from '../services/registry.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /instances/register — Register a slug→basePath mapping
|
||||
router.post('/instances/register', async (req: Request, res: Response) => {
|
||||
const { slug, basePath, composeProject } = req.body;
|
||||
if (!slug || !basePath || !composeProject) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'slug, basePath, and composeProject required' });
|
||||
return;
|
||||
}
|
||||
await registerSlug(slug, basePath, composeProject);
|
||||
res.json({ registered: slug });
|
||||
});
|
||||
|
||||
// DELETE /instances/:slug — Unregister slug
|
||||
router.delete('/instances/:slug', async (req: Request, res: Response) => {
|
||||
await unregisterSlug(param(req, 'slug'));
|
||||
res.json({ unregistered: param(req, 'slug') });
|
||||
});
|
||||
|
||||
// GET /instances — List all managed slugs
|
||||
router.get('/instances', async (_req: Request, res: Response) => {
|
||||
const slugs = await listSlugs();
|
||||
res.json(slugs);
|
||||
});
|
||||
|
||||
export default router;
|
||||
79
changemaker-control-panel/agent/src/routes/upgrade.routes.ts
Normal file
79
changemaker-control-panel/agent/src/routes/upgrade.routes.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { param } from '../utils/params';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getSlugEntry } from '../services/registry.service';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const router = Router();
|
||||
|
||||
/** Validate a git branch name — prevent shell injection. */
|
||||
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
|
||||
|
||||
// POST /instance/:slug/upgrade/start — Run upgrade.sh
|
||||
router.post('/instance/:slug/upgrade/start', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const { skipBackup, useRegistry, branch } = req.body || {};
|
||||
|
||||
// SECURITY: Validate branch name to prevent injection
|
||||
if (branch && !SAFE_BRANCH.test(branch)) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'Invalid branch name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = path.join(entry.basePath, 'scripts', 'upgrade.sh');
|
||||
try {
|
||||
await fs.access(scriptPath);
|
||||
} catch {
|
||||
res.status(400).json({ error: 'NOT_FOUND', message: 'upgrade.sh not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Use execFile with args array — no shell interpolation
|
||||
const args = ['--api-mode', '--force'];
|
||||
if (skipBackup) args.push('--skip-backup');
|
||||
if (useRegistry) args.push('--use-registry');
|
||||
if (branch) args.push('--branch', branch);
|
||||
|
||||
// Fire-and-forget — CCP polls progress
|
||||
execFileAsync('bash', [scriptPath, ...args], {
|
||||
cwd: entry.basePath,
|
||||
timeout: 600_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).catch((err) => {
|
||||
logger.error(`[upgrade] ${param(req, 'slug')} failed: ${(err as Error).message}`);
|
||||
});
|
||||
|
||||
res.json({ started: true });
|
||||
});
|
||||
|
||||
// GET /instance/:slug/upgrade/progress — Read progress.json
|
||||
router.get('/instance/:slug/upgrade/progress', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(progressPath, 'utf-8');
|
||||
res.json(JSON.parse(content));
|
||||
} catch {
|
||||
res.json({ phase: 0, percentage: 0, message: 'Waiting for upgrade to start...' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /instance/:slug/upgrade/result — Read result.json
|
||||
router.get('/instance/:slug/upgrade/result', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(resultPath, 'utf-8');
|
||||
res.json(JSON.parse(content));
|
||||
} catch {
|
||||
res.status(404).json({ error: 'NOT_FOUND', message: 'No upgrade result available' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
159
changemaker-control-panel/agent/src/server.ts
Normal file
159
changemaker-control-panel/agent/src/server.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'express-async-errors';
|
||||
import express from 'express';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import { env } from './config/env';
|
||||
import { logger } from './utils/logger';
|
||||
import { mtlsAuth } from './middleware/mtls-auth';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import healthRoutes from './routes/health.routes';
|
||||
import composeRoutes from './routes/compose.routes';
|
||||
import filesRoutes from './routes/files.routes';
|
||||
import registryRoutes from './routes/registry.routes';
|
||||
import backupRoutes from './routes/backup.routes';
|
||||
import upgradeRoutes from './routes/upgrade.routes';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Parse JSON bodies (up to 50MB for template file uploads)
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Health endpoint is always accessible (no mTLS required)
|
||||
app.use(healthRoutes);
|
||||
|
||||
// All other routes require mTLS authentication
|
||||
function hasCerts(): boolean {
|
||||
try {
|
||||
fs.accessSync(env.AGENT_CERT_PATH);
|
||||
fs.accessSync(env.AGENT_KEY_PATH);
|
||||
fs.accessSync(env.AGENT_CA_CERT_PATH);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCerts()) {
|
||||
// mTLS mode — certificates are installed
|
||||
const tlsOptions: https.ServerOptions = {
|
||||
key: fs.readFileSync(env.AGENT_KEY_PATH),
|
||||
cert: fs.readFileSync(env.AGENT_CERT_PATH),
|
||||
ca: fs.readFileSync(env.AGENT_CA_CERT_PATH),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
};
|
||||
|
||||
app.use(mtlsAuth);
|
||||
app.use(composeRoutes);
|
||||
app.use(filesRoutes);
|
||||
app.use(registryRoutes);
|
||||
app.use(backupRoutes);
|
||||
app.use(upgradeRoutes);
|
||||
app.use(errorHandler);
|
||||
|
||||
const server = https.createServer(tlsOptions, app);
|
||||
server.listen(env.AGENT_PORT, () => {
|
||||
logger.info(`CCP Agent (mTLS) listening on port ${env.AGENT_PORT}`);
|
||||
});
|
||||
} else {
|
||||
// Pre-approval mode — start HTTP, only health + phone-home polling
|
||||
logger.info('No certificates found — starting in phone-home registration mode');
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
const server = http.createServer(app);
|
||||
server.listen(env.AGENT_PORT, () => {
|
||||
logger.info(`CCP Agent (registration mode) listening on port ${env.AGENT_PORT}`);
|
||||
});
|
||||
|
||||
// Start phone-home polling if CCP_URL and CCP_INVITE_CODE are set
|
||||
if (env.CCP_URL && env.CCP_INVITE_CODE) {
|
||||
startPhoneHome();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone-home registration flow:
|
||||
* 1. POST to CCP with invite code + instance metadata
|
||||
* 2. Poll CCP every 30s until approved
|
||||
* 3. On approval, save certs and restart with mTLS
|
||||
*/
|
||||
async function startPhoneHome() {
|
||||
logger.info(`[phone-home] Registering with CCP at ${env.CCP_URL}...`);
|
||||
|
||||
// Step 1: Send registration request
|
||||
try {
|
||||
const response = await fetch(`${env.CCP_URL}/api/agents/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inviteCode: env.CCP_INVITE_CODE,
|
||||
slug: env.INSTANCE_SLUG,
|
||||
name: env.INSTANCE_SLUG,
|
||||
domain: env.INSTANCE_DOMAIN,
|
||||
agentUrl: env.CCP_AGENT_URL,
|
||||
basePath: env.INSTANCE_BASE_PATH,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
logger.error(`[phone-home] Registration failed: ${response.status} ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json() as { registrationId: string };
|
||||
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
|
||||
|
||||
// Step 2: Poll for approval
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const pollResp = await fetch(
|
||||
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
|
||||
);
|
||||
|
||||
if (!pollResp.ok) return;
|
||||
|
||||
const pollData = await pollResp.json() as {
|
||||
status: string;
|
||||
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
|
||||
};
|
||||
|
||||
if (pollData.status === 'APPROVED' && pollData.certBundle) {
|
||||
clearInterval(pollInterval);
|
||||
logger.info('[phone-home] Approved! Saving certificates...');
|
||||
|
||||
// Save certs
|
||||
const fsp = await import('fs/promises');
|
||||
const pathMod = await import('path');
|
||||
await fsp.mkdir(pathMod.dirname(env.AGENT_CERT_PATH), { recursive: true });
|
||||
await fsp.writeFile(env.AGENT_CERT_PATH, pollData.certBundle.agentCertPem);
|
||||
await fsp.writeFile(env.AGENT_KEY_PATH, pollData.certBundle.agentKeyPem);
|
||||
await fsp.writeFile(env.AGENT_CA_CERT_PATH, pollData.certBundle.caCertPem);
|
||||
|
||||
// SECURITY: Write the CCP fingerprint to a config file so the agent
|
||||
// can verify the CCP's identity on subsequent connections.
|
||||
if (pollData.certBundle.ccpFingerprint) {
|
||||
const configPath = pathMod.join(env.AGENT_DATA_DIR, 'ccp-fingerprint');
|
||||
await fsp.mkdir(env.AGENT_DATA_DIR, { recursive: true });
|
||||
await fsp.writeFile(configPath, pollData.certBundle.ccpFingerprint);
|
||||
logger.info(`[phone-home] CCP fingerprint saved: ${pollData.certBundle.ccpFingerprint.substring(0, 16)}...`);
|
||||
}
|
||||
|
||||
logger.info('[phone-home] Certificates saved. Restarting with mTLS...');
|
||||
|
||||
// Exit so Docker restart policy brings us back with certs
|
||||
process.exit(0);
|
||||
} else if (pollData.status === 'REJECTED') {
|
||||
clearInterval(pollInterval);
|
||||
logger.error('[phone-home] Registration was rejected by CCP admin');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
|
||||
}
|
||||
}, 30_000);
|
||||
} catch (err) {
|
||||
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
134
changemaker-control-panel/agent/src/services/docker.service.ts
Normal file
134
changemaker-control-panel/agent/src/services/docker.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
const EXEC_TIMEOUT = 120_000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function validateDuration(value: string): string {
|
||||
if (!/^\d+[smhd]$/.test(value)) throw new Error(`Invalid duration: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateTail(value: number): number {
|
||||
return Math.floor(Math.max(1, Math.min(value, 5000)));
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
name: string;
|
||||
service: string;
|
||||
status: string;
|
||||
state: string;
|
||||
health: string;
|
||||
ports: string;
|
||||
createdAt: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
async function execCmd(command: string, cwd: string, timeoutMs = EXEC_TIMEOUT) {
|
||||
logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`);
|
||||
try {
|
||||
return await exec(command, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, COMPOSE_ANSI: 'never' },
|
||||
});
|
||||
} 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}`);
|
||||
throw new Error(`Command failed: ${command}\n${error.stderr || error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function composeCmd(project: string): string {
|
||||
return `docker compose -p ${validateName(project, 'project')}`;
|
||||
}
|
||||
|
||||
export async function composeUp(projectDir: string, project: string, services?: 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) {
|
||||
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) {
|
||||
const { stdout, stderr } = await execCmd(`${composeCmd(project)} stop`, projectDir);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composeRestart(projectDir: string, project: string, service?: 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) {
|
||||
const { stdout, stderr } = await execCmd(`${composeCmd(project)} pull`, projectDir, 300_000);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composeBuild(projectDir: string, project: string) {
|
||||
const { stdout, stderr } = await execCmd(`${composeCmd(project)} build`, projectDir, 600_000);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composePs(projectDir: string, project: string): Promise<ContainerInfo[]> {
|
||||
const { stdout } = await execCmd(`${composeCmd(project)} ps --format json`, projectDir);
|
||||
if (!stdout.trim()) return [];
|
||||
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 { /* skip */ }
|
||||
}
|
||||
return containers;
|
||||
}
|
||||
|
||||
export async function composeLogs(projectDir: string, project: string, service?: string, tail = 200, since?: 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;
|
||||
}
|
||||
|
||||
export async function composeExec(
|
||||
projectDir: string, project: string, service: string,
|
||||
command: string, timeoutMs = EXEC_TIMEOUT, envVars?: Record<string, string>
|
||||
) {
|
||||
const envFlags = envVars
|
||||
? 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}`,
|
||||
projectDir, timeoutMs
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
104
changemaker-control-panel/agent/src/services/file.service.ts
Normal file
104
changemaker-control-panel/agent/src/services/file.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import { AgentError } from '../middleware/error-handler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Validate that a resolved path is within the allowed basePath.
|
||||
* Prevents path traversal attacks.
|
||||
*/
|
||||
function assertWithin(filePath: string, basePath: string): void {
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
if (!resolvedFile.startsWith(resolvedBase + '/') && resolvedFile !== resolvedBase) {
|
||||
throw new AgentError(403, `Path ${filePath} is outside allowed directory`, 'PATH_TRAVERSAL');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readEnvFile(basePath: string): Promise<Record<string, string>> {
|
||||
const envPath = path.join(basePath, '.env');
|
||||
const content = await fs.readFile(envPath, 'utf-8');
|
||||
return parseDotenv(Buffer.from(content));
|
||||
}
|
||||
|
||||
export async function writeFiles(
|
||||
basePath: string,
|
||||
files: Array<{ relativePath: string; content: string }>
|
||||
): Promise<void> {
|
||||
for (const file of files) {
|
||||
const filePath = path.join(basePath, file.relativePath);
|
||||
assertWithin(filePath, basePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, file.content, 'utf-8');
|
||||
logger.debug(`[files] Wrote ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mkdirp(basePath: string, relativePath: string): Promise<void> {
|
||||
const dirPath = path.join(basePath, relativePath);
|
||||
assertWithin(dirPath, basePath);
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
/** Validate git repo URL and branch name to prevent shell injection. */
|
||||
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
|
||||
const SAFE_REPO = /^[a-zA-Z0-9@:._\/-]+$/;
|
||||
const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
|
||||
|
||||
export async function cloneSource(
|
||||
basePath: string,
|
||||
gitRepo: string,
|
||||
gitBranch: string,
|
||||
excludes?: string[]
|
||||
): Promise<void> {
|
||||
// SECURITY: Validate inputs before any shell execution
|
||||
if (!SAFE_REPO.test(gitRepo)) {
|
||||
throw new AgentError(400, 'Invalid git repository URL', 'VALIDATION');
|
||||
}
|
||||
if (!SAFE_BRANCH.test(gitBranch)) {
|
||||
throw new AgentError(400, 'Invalid git branch name', 'VALIDATION');
|
||||
}
|
||||
|
||||
const { execFile } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Ensure base directory exists
|
||||
await fs.mkdir(basePath, { recursive: true });
|
||||
|
||||
// Clone into a temp directory first, then move contents
|
||||
const tmpDir = `${basePath}.tmp-${Date.now()}`;
|
||||
try {
|
||||
// SECURITY: Use execFile with args array — no shell interpolation
|
||||
await execFileAsync('git', ['clone', '--branch', gitBranch, '--depth', '1', gitRepo, tmpDir], {
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
// Remove git metadata and excluded directories
|
||||
const defaultExcludes = excludes || [
|
||||
'.git', 'node_modules', 'changemaker-control-panel', '.claude',
|
||||
'api/dist', 'admin/dist',
|
||||
];
|
||||
for (const exclude of defaultExcludes) {
|
||||
// SECURITY: Validate each exclude entry
|
||||
if (!SAFE_EXCLUDE.test(exclude)) continue;
|
||||
const excludePath = path.join(tmpDir, exclude);
|
||||
// SECURITY: Verify exclude path is within tmpDir
|
||||
if (!path.resolve(excludePath).startsWith(path.resolve(tmpDir) + '/')) continue;
|
||||
try {
|
||||
await fs.rm(excludePath, { recursive: true, force: true });
|
||||
} catch { /* ignore if doesn't exist */ }
|
||||
}
|
||||
|
||||
// Move contents to basePath using execFile (no shell)
|
||||
await execFileAsync('rsync', ['-a', `${tmpDir}/`, `${basePath}/`], { timeout: 120_000 });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
|
||||
logger.info(`[files] Cloned ${gitRepo}@${gitBranch} → ${basePath}`);
|
||||
} catch (err) {
|
||||
// Clean up temp dir on failure
|
||||
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
import { AgentError } from '../middleware/error-handler';
|
||||
|
||||
interface SlugEntry {
|
||||
basePath: string;
|
||||
composeProject: string;
|
||||
registeredAt: string;
|
||||
}
|
||||
|
||||
type Registry = Record<string, SlugEntry>;
|
||||
|
||||
const registryPath = () => path.join(env.AGENT_DATA_DIR, 'registry.json');
|
||||
|
||||
let cache: Registry | null = null;
|
||||
|
||||
async function loadRegistry(): Promise<Registry> {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const data = await fs.readFile(registryPath(), 'utf-8');
|
||||
cache = JSON.parse(data) as Registry;
|
||||
return cache;
|
||||
} catch {
|
||||
cache = {};
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRegistry(registry: Registry): Promise<void> {
|
||||
await fs.mkdir(env.AGENT_DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(registryPath(), JSON.stringify(registry, null, 2), 'utf-8');
|
||||
cache = registry;
|
||||
}
|
||||
|
||||
export async function registerSlug(slug: string, basePath: string, composeProject: string): Promise<void> {
|
||||
const registry = await loadRegistry();
|
||||
registry[slug] = {
|
||||
basePath,
|
||||
composeProject,
|
||||
registeredAt: new Date().toISOString(),
|
||||
};
|
||||
await saveRegistry(registry);
|
||||
logger.info(`[registry] Registered slug ${slug} → ${basePath} (project: ${composeProject})`);
|
||||
}
|
||||
|
||||
export async function unregisterSlug(slug: string): Promise<void> {
|
||||
const registry = await loadRegistry();
|
||||
if (!registry[slug]) {
|
||||
throw new AgentError(404, `Slug ${slug} not registered`);
|
||||
}
|
||||
delete registry[slug];
|
||||
await saveRegistry(registry);
|
||||
logger.info(`[registry] Unregistered slug ${slug}`);
|
||||
}
|
||||
|
||||
export async function getSlugEntry(slug: string): Promise<SlugEntry> {
|
||||
const registry = await loadRegistry();
|
||||
const entry = registry[slug];
|
||||
if (!entry) {
|
||||
throw new AgentError(404, `Slug ${slug} not registered`, 'SLUG_NOT_FOUND');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function listSlugs(): Promise<Record<string, SlugEntry>> {
|
||||
return loadRegistry();
|
||||
}
|
||||
12
changemaker-control-panel/agent/src/utils/logger.ts
Normal file
12
changemaker-control-panel/agent/src/utils/logger.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import winston from 'winston';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: env.AGENT_LOG_LEVEL,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`)
|
||||
),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
11
changemaker-control-panel/agent/src/utils/params.ts
Normal file
11
changemaker-control-panel/agent/src/utils/params.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Extract a route parameter as a string.
|
||||
* Express 5 types params as string | string[]; this helper narrows it.
|
||||
*/
|
||||
export function param(req: Request, name: string): string {
|
||||
const val = req.params[name];
|
||||
if (Array.isArray(val)) return val[0];
|
||||
return val;
|
||||
}
|
||||
Reference in New Issue
Block a user