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:
parent
c2f7a23b16
commit
67b21ea960
@ -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={
|
||||
|
||||
@ -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' },
|
||||
|
||||
254
admin/src/pages/ControlPanelPage.tsx
Normal file
254
admin/src/pages/ControlPanelPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user