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:
@@ -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),
|
||||
|
||||
182
api/src/modules/ccp-registration/ccp-registration.routes.ts
Normal file
182
api/src/modules/ccp-registration/ccp-registration.routes.ts
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user