diff --git a/admin/src/App.tsx b/admin/src/App.tsx index be4c2401..dda59751 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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() { } /> + + + + } + /> , label: 'Tunnel' }, + { key: '/app/control-panel', icon: , label: 'Control Panel' }, { key: '/app/observability', icon: , label: 'Monitoring' }, { key: '/app/services/nocodb', icon: , label: 'Database' }, { key: '/app/services/vaultwarden', icon: , label: 'Vault' }, diff --git a/admin/src/pages/ControlPanelPage.tsx b/admin/src/pages/ControlPanelPage.tsx new file mode 100644 index 00000000..840ce1e4 --- /dev/null +++ b/admin/src/pages/ControlPanelPage.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+ +
+ + <CloudServerOutlined style={{ marginRight: 8 }} /> + Control Panel Registration + + + Register this instance with a Changemaker Control Panel (CCP) for remote management, + health monitoring, backups, and upgrades. + +
+ + {status?.registered ? ( + // ─── Registered State ──────────────────────────────── + + } + /> + + + + + + {status.ccpUrl} + + + + {status.agentUrl} + + + {status.agentRunning ? ( + } color="success">Running + ) : ( + } color="error">Not Running + )} + + + + {!status.agentRunning && ( + + )} + +
+ + + +
+
+
+ ) : ( + // ─── Unregistered State ────────────────────────────── + + + + +
+ + } /> + + + + + + + + } /> + + + + + +
+
+ + +
    +
  1. Your CCP admin generates an invite code and shares it with you
  2. +
  3. Enter the CCP URL, invite code, and this server's agent URL above
  4. +
  5. The CCP agent starts and sends a registration request to the CCP
  6. +
  7. The CCP admin approves the registration
  8. +
  9. mTLS certificates are issued automatically for secure communication
  10. +
  11. The CCP can now manage this instance remotely
  12. +
+
+
+ )} +
+
+ ); +} diff --git a/api/src/config/env.ts b/api/src/config/env.ts index cb8acdb4..221b45c4 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -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), diff --git a/api/src/modules/ccp-registration/ccp-registration.routes.ts b/api/src/modules/ccp-registration/ccp-registration.routes.ts new file mode 100644 index 00000000..96ff4ae4 --- /dev/null +++ b/api/src/modules/ccp-registration/ccp-registration.routes.ts @@ -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; diff --git a/api/src/server.ts b/api/src/server.ts index aa04d0f0..9e063a17 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0115d8ca..084ee9f3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index 61f7149a..109e9ea0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}