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:
bunker-admin 2026-04-08 15:13:28 -06:00
parent c2f7a23b16
commit 67b21ea960
8 changed files with 464 additions and 0 deletions

View File

@ -43,6 +43,7 @@ import JitsiMeetPage from '@/pages/JitsiMeetPage';
import SettingsPage from '@/pages/SettingsPage';
import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ControlPanelPage from '@/pages/ControlPanelPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import AnalyticsOverviewPage from '@/pages/analytics/AnalyticsOverviewPage';
@ -849,6 +850,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="control-panel"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<ControlPanelPage />
</ProtectedRoute>
}
/>
<Route
path="observability"
element={

View File

@ -352,6 +352,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
children: [
{ type: 'group', label: 'Infrastructure', children: [
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/control-panel', icon: <ApiOutlined />, label: 'Control Panel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Monitoring' },
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' },

View File

@ -0,0 +1,254 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography,
Card,
Form,
Input,
Button,
Alert,
Space,
Descriptions,
Tag,
Spin,
Popconfirm,
message,
} from 'antd';
import {
CloudServerOutlined,
LinkOutlined,
DisconnectOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
const { Title, Paragraph } = Typography;
interface RegistrationStatus {
registered: boolean;
ccpUrl: string | null;
agentUrl: string | null;
agentRunning: boolean;
}
export default function ControlPanelPage() {
const [status, setStatus] = useState<RegistrationStatus | null>(null);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState(false);
const [unregistering, setUnregistering] = useState(false);
const [form] = Form.useForm();
const fetchStatus = useCallback(async () => {
try {
setLoading(true);
const { data } = await api.get('/api/ccp-registration/status');
setStatus(data);
} catch {
message.error('Failed to load registration status');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchStatus(); }, [fetchStatus]);
const handleRegister = async (values: { ccpUrl: string; inviteCode: string; agentUrl: string }) => {
try {
setRegistering(true);
const { data } = await api.post('/api/ccp-registration/register', values);
if (data.success) {
message.success(data.message);
fetchStatus();
} else {
message.error(data.message || 'Registration failed');
}
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: { message?: string } } } };
message.error(error?.response?.data?.error?.message || 'Registration failed');
} finally {
setRegistering(false);
}
};
const handleUnregister = async () => {
try {
setUnregistering(true);
const { data } = await api.post('/api/ccp-registration/unregister');
if (data.success) {
message.success(data.message);
form.resetFields();
fetchStatus();
}
} catch {
message.error('Failed to unregister');
} finally {
setUnregistering(false);
}
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ padding: 24, maxWidth: 800 }}>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<Title level={3} style={{ margin: 0 }}>
<CloudServerOutlined style={{ marginRight: 8 }} />
Control Panel Registration
</Title>
<Paragraph type="secondary" style={{ marginTop: 8 }}>
Register this instance with a Changemaker Control Panel (CCP) for remote management,
health monitoring, backups, and upgrades.
</Paragraph>
</div>
{status?.registered ? (
// ─── Registered State ────────────────────────────────
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="success"
message="Registered with Control Panel"
description="This instance is connected to a CCP. The agent handles health checks, lifecycle operations, and configuration sync."
showIcon
icon={<CheckCircleOutlined />}
/>
<Card title="Connection Details">
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Control Panel URL">
<a href={status.ccpUrl || '#'} target="_blank" rel="noopener noreferrer">
{status.ccpUrl}
</a>
</Descriptions.Item>
<Descriptions.Item label="Agent URL">
{status.agentUrl}
</Descriptions.Item>
<Descriptions.Item label="Agent Status">
{status.agentRunning ? (
<Tag icon={<CheckCircleOutlined />} color="success">Running</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">Not Running</Tag>
)}
</Descriptions.Item>
</Descriptions>
{!status.agentRunning && (
<Alert
type="warning"
message="Agent container is not running"
description="The CCP agent may still be waiting for approval, or it may have stopped. Check the container logs for details."
showIcon
style={{ marginTop: 16 }}
/>
)}
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Popconfirm
title="Unregister from Control Panel?"
description="This will stop the agent and remove the CCP connection. You can re-register later with a new invite code."
onConfirm={handleUnregister}
okText="Yes, Unregister"
okButtonProps={{ danger: true }}
>
<Button
danger
icon={<DisconnectOutlined />}
loading={unregistering}
>
Unregister
</Button>
</Popconfirm>
</div>
</Card>
</Space>
) : (
// ─── Unregistered State ──────────────────────────────
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
message="Not registered with a Control Panel"
description="Enter the CCP URL and invite code provided by your CCP administrator to register this instance for remote management."
showIcon
/>
<Card title="Register with Control Panel">
<Form
form={form}
layout="vertical"
onFinish={handleRegister}
style={{ maxWidth: 500 }}
>
<Form.Item
label="Control Panel URL"
name="ccpUrl"
rules={[
{ required: true, message: 'CCP URL is required' },
{ type: 'url', message: 'Must be a valid URL' },
]}
extra="The URL of the Changemaker Control Panel (e.g., https://ccp.example.com)"
>
<Input placeholder="https://ccp.example.com" prefix={<LinkOutlined />} />
</Form.Item>
<Form.Item
label="Invite Code"
name="inviteCode"
rules={[{ required: true, message: 'Invite code is required' }]}
extra="Single-use code generated by the CCP admin (e.g., ABCD-1234)"
>
<Input
placeholder="XXXX-XXXX"
style={{ fontFamily: 'monospace', letterSpacing: 2 }}
maxLength={20}
/>
</Form.Item>
<Form.Item
label="Agent URL"
name="agentUrl"
rules={[
{ required: true, message: 'Agent URL is required' },
{ type: 'url', message: 'Must be a valid URL' },
]}
extra="How the CCP can reach this machine (must be externally accessible). Include port 7443."
>
<Input placeholder="https://this-server:7443" prefix={<LinkOutlined />} />
</Form.Item>
<Form.Item style={{ marginTop: 24 }}>
<Button
type="primary"
htmlType="submit"
icon={registering ? <LoadingOutlined /> : <CloudServerOutlined />}
loading={registering}
size="large"
>
Register with Control Panel
</Button>
</Form.Item>
</Form>
</Card>
<Card title="How it works" size="small">
<ol style={{ paddingLeft: 20, margin: 0 }}>
<li>Your CCP admin generates an invite code and shares it with you</li>
<li>Enter the CCP URL, invite code, and this server's agent URL above</li>
<li>The CCP agent starts and sends a registration request to the CCP</li>
<li>The CCP admin approves the registration</li>
<li>mTLS certificates are issued automatically for secure communication</li>
<li>The CCP can now manage this instance remotely</li>
</ol>
</Card>
</Space>
)}
</Space>
</div>
);
}

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)

View File

@ -106,6 +106,11 @@ services:
- ENABLE_SOCIAL=${ENABLE_SOCIAL:-false}
- ENABLE_PEOPLE=${ENABLE_PEOPLE:-false}
- ENABLE_ANALYTICS=${ENABLE_ANALYTICS:-false}
# CCP Agent (remote management)
- ENABLE_CCP_AGENT=${ENABLE_CCP_AGENT:-false}
- CCP_URL=${CCP_URL:-}
- CCP_AGENT_URL=${CCP_AGENT_URL:-}
- COMPOSE_PROFILES=${COMPOSE_PROFILES:-}
- TERMUX_API_URL=${TERMUX_API_URL:-http://10.0.0.193:5001}
- TERMUX_API_KEY=${TERMUX_API_KEY:-}
- SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000}

View File

@ -107,6 +107,11 @@ services:
- ENABLE_SOCIAL=${ENABLE_SOCIAL:-false}
- ENABLE_PEOPLE=${ENABLE_PEOPLE:-false}
- ENABLE_ANALYTICS=${ENABLE_ANALYTICS:-false}
# CCP Agent (remote management)
- ENABLE_CCP_AGENT=${ENABLE_CCP_AGENT:-false}
- CCP_URL=${CCP_URL:-}
- CCP_AGENT_URL=${CCP_AGENT_URL:-}
- COMPOSE_PROFILES=${COMPOSE_PROFILES:-}
- TERMUX_API_URL=${TERMUX_API_URL:-http://10.0.0.193:5001}
- TERMUX_API_KEY=${TERMUX_API_KEY:-}
- SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000}