Add CCP registration page to CML admin panel

Operators can now register with a Control Panel directly from the admin
GUI (Services → Control Panel) without SSH access. Uses the existing
updateEnvFile + dockerService pattern from the Pangolin setup.

New endpoint: /api/ccp-registration (status, register, unregister)
New page: ControlPanelPage with form for CCP URL, invite code, agent URL
Also passes CCP env vars through docker-compose to the API container.

Bunker Admin
This commit is contained in:
2026-04-08 15:13:28 -06:00
parent 215da79284
commit c6f8a49925
8 changed files with 464 additions and 0 deletions

View File

@@ -212,6 +212,12 @@ const envSchema = z.object({
ENABLE_SOCIAL: z.string().default('false'),
ENABLE_PEOPLE: z.string().default('false'),
ENABLE_ANALYTICS: z.string().default('false'),
// CCP Agent (remote management)
ENABLE_CCP_AGENT: z.string().default('false'),
CCP_URL: z.string().default(''),
CCP_AGENT_URL: z.string().default(''),
COMPOSE_PROFILES: z.string().default(''),
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),
TERMUX_API_KEY: z.string().default(''),
SMS_DELAY_BETWEEN_MS: z.coerce.number().default(3000),

View File

@@ -0,0 +1,182 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { updateEnvFile } from '../../services/env-writer.service';
import { dockerService } from '../../services/docker.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
const router = Router();
// ─── Schemas ─────────────────────────────────────────────────────────
const registerSchema = z.object({
ccpUrl: z.string().url().regex(/^https?:\/\//, 'Must be a valid URL'),
inviteCode: z.string().min(4).max(20),
agentUrl: z.string().url().regex(/^https?:\/\//, 'Must be a valid URL'),
});
// ─── GET /api/ccp-registration/status ────────────────────────────────
/**
* Check current CCP registration status by reading env vars.
*/
router.get(
'/status',
authenticate,
requireRole('SUPER_ADMIN'),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const ccpUrl = env.CCP_URL || '';
const agentUrl = env.CCP_AGENT_URL || '';
const enabled = env.ENABLE_CCP_AGENT === 'true';
// Check if agent container is running
let agentRunning = false;
try {
const status = await dockerService.getContainerStatus('ccp-agent');
agentRunning = status.running;
} catch {
// Container doesn't exist or isn't running
}
res.json({
registered: enabled && !!ccpUrl,
ccpUrl: ccpUrl || null,
agentUrl: agentUrl || null,
agentRunning,
});
} catch (err) {
next(err);
}
}
);
// ─── POST /api/ccp-registration/register ─────────────────────────────
/**
* Register this instance with a CCP.
* Updates .env and starts the ccp-agent container.
*/
router.post(
'/register',
authenticate,
requireRole('SUPER_ADMIN'),
validate(registerSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { ccpUrl, inviteCode, agentUrl } = req.body;
logger.info(`[ccp-registration] Registering with CCP at ${ccpUrl}`);
// Step 1: Update .env with CCP configuration
const envResult = updateEnvFile({
ENABLE_CCP_AGENT: 'true',
CCP_URL: ccpUrl,
CCP_INVITE_CODE: inviteCode,
CCP_AGENT_URL: agentUrl,
});
if (!envResult.success) {
res.status(500).json({
error: { message: `Failed to update .env: ${envResult.error}`, code: 'ENV_WRITE_FAILED' },
});
return;
}
// Step 2: Ensure ccp-agent is in COMPOSE_PROFILES
const profileResult = updateEnvFile({
COMPOSE_PROFILES: addProfile(env.COMPOSE_PROFILES || '', 'ccp-agent'),
});
if (!profileResult.success) {
logger.warn(`[ccp-registration] Failed to update COMPOSE_PROFILES: ${profileResult.error}`);
}
// Step 3: Start the ccp-agent container
let agentStarted = false;
let agentOutput = '';
try {
const result = await dockerService.restartContainer('ccp-agent');
agentStarted = result.success;
agentOutput = result.output;
} catch (err) {
agentOutput = (err as Error).message;
logger.warn(`[ccp-registration] Failed to start agent: ${agentOutput}`);
}
logger.info(`[ccp-registration] Registration initiated: env updated, agent ${agentStarted ? 'started' : 'failed to start'}`);
res.json({
success: true,
envUpdated: true,
agentStarted,
agentOutput: agentStarted ? undefined : agentOutput,
message: agentStarted
? 'Registration initiated — agent is phoning home to the CCP. Waiting for admin approval.'
: 'Environment configured but agent container failed to start. Check docker compose logs.',
});
} catch (err) {
next(err);
}
}
);
// ─── POST /api/ccp-registration/unregister ───────────────────────────
/**
* Remove CCP registration and stop the agent.
*/
router.post(
'/unregister',
authenticate,
requireRole('SUPER_ADMIN'),
async (_req: Request, res: Response, next: NextFunction) => {
try {
logger.info('[ccp-registration] Unregistering from CCP');
// Clear env vars
updateEnvFile({
ENABLE_CCP_AGENT: 'false',
CCP_URL: '',
CCP_INVITE_CODE: '',
CCP_AGENT_URL: '',
COMPOSE_PROFILES: removeProfile(env.COMPOSE_PROFILES || '', 'ccp-agent'),
});
// Stop the agent container
try {
// Use docker compose stop instead of up -d
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync('docker compose --profile ccp-agent stop ccp-agent', {
cwd: '/app',
timeout: 30_000,
});
} catch {
// Agent might not be running — that's fine
}
res.json({ success: true, message: 'CCP registration removed and agent stopped.' });
} catch (err) {
next(err);
}
}
);
// ─── Helpers ─────────────────────────────────────────────────────────
function addProfile(current: string, profile: string): string {
const profiles = current.split(',').map(p => p.trim()).filter(Boolean);
if (!profiles.includes(profile)) profiles.push(profile);
return profiles.join(',');
}
function removeProfile(current: string, profile: string): string {
return current.split(',').map(p => p.trim()).filter(p => p && p !== profile).join(',');
}
export default router;

View File

@@ -53,6 +53,7 @@ import { trackingVolunteerRouter, trackingAdminRouter } from './modules/map/trac
import { geocodingRouter } from './modules/map/geocoding/geocoding.routes';
import { eventsPublicRouter } from './modules/map/events/events.routes';
import { pangolinRouter } from './modules/pangolin/pangolin.routes';
import ccpRegistrationRouter from './modules/ccp-registration/ccp-registration.routes';
import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes';
import { jitsiRouter } from './modules/jitsi/jitsi.routes';
import { rocketchatWebhookService } from './services/rocketchat-webhook.service';
@@ -337,6 +338,7 @@ app.use('/api/map/tracking', trackingVolunteerRouter); // Volunteer GPS track
app.use('/api/map/tracking', trackingAdminRouter); // Admin GPS tracking (MAP_ADMIN+)
app.use('/api/settings', siteSettingsRouter); // Site settings (public GET, SUPER_ADMIN PUT)
app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
app.use('/api/ccp-registration', ccpRegistrationRouter); // CCP remote management registration (SUPER_ADMIN)
app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required)
app.use('/api/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required)
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)