Add remote instance management with mTLS agent and phone-home registration

Enables the CCP to manage CML instances on remote servers via a lightweight
HTTP agent. Key components:

- ExecutionDriver abstraction (local-driver.ts / remote-driver.ts) routes
  operations to local Docker or remote agent transparently
- Remote agent package (agent/) with mTLS authentication, Docker Compose
  operations, file management, backup/upgrade delegation
- Certificate service using openssl CLI for CA management and cert issuance
- Phone-home registration: remote agents register via invite code, CCP admin
  approves, agent receives mTLS cert bundle automatically
- config.sh integration with configure_control_panel() section
- ccp-agent Docker Compose service (profile-gated)
- Frontend: AgentRegistrationsPage, InviteCodesPage, Remote Agents sidebar menu
- Security hardened: cert bundle wiped after delivery, shell injection prevention
  via execFile, command allowlist with metachar rejection, rate-limited public
  endpoints, auto-populated fingerprint pinning

Also wires ENABLE_SOCIAL/PEOPLE/ANALYTICS through env.ts, seed.ts, and
docker-compose env passthrough (from previous session).

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-07 15:24:33 -06:00
parent d17e197a1b
commit 38ccaa8a5b
52 changed files with 4939 additions and 108 deletions

View File

@ -403,6 +403,26 @@ SMS_MAX_RETRIES=3
SMS_RESPONSE_SYNC_INTERVAL_MS=120000
SMS_DEVICE_MONITOR_INTERVAL_MS=300000
# --- Social, People & Analytics ---
# ENABLE_SOCIAL is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_SOCIAL=false
# ENABLE_PEOPLE is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_PEOPLE=false
# ENABLE_ANALYTICS is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_ANALYTICS=false
# --- Control Panel Agent ---
# Set to true to enable the CCP remote management agent
ENABLE_CCP_AGENT=false
# URL of the Changemaker Control Panel
CCP_URL=
# One-time invite code for registration
CCP_INVITE_CODE=
# How the CCP can reach this agent (must be externally accessible)
CCP_AGENT_URL=
# Agent port (default 7443)
CCP_AGENT_PORT=7443
# --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090
GRAFANA_PORT=3005

View File

@ -1025,7 +1025,7 @@ model MapSettings {
qrCode2Label String?
qrCode3Url String?
qrCode3Label String?
publicMapEnabled Boolean @default(true)
publicMapEnabled Boolean @default(false)
publicShowLocations Boolean @default(true)
publicShowSupportLevels Boolean @default(true)
publicShowCuts Boolean @default(true)
@ -1087,7 +1087,7 @@ model SiteSettings {
// Feature toggles
enableInfluence Boolean @default(true)
enableMap Boolean @default(true)
enableMap Boolean @default(false)
enableNewsletter Boolean @default(true)
enableLandingPages Boolean @default(true)
enableMediaFeatures Boolean @default(true) @map("enable_media_features")

View File

@ -102,6 +102,17 @@ async function main() {
smtpActiveProvider: isMailhog ? 'mailhog' : 'production',
emailTestMode: env.EMAIL_TEST_MODE === 'true',
testEmailRecipient: env.TEST_EMAIL_RECIPIENT,
// Feature flags from .env (DB authoritative once admin saves settings)
enableMediaFeatures: env.ENABLE_MEDIA_FEATURES !== 'false',
enableChat: env.ENABLE_CHAT === 'true',
enableMeet: env.ENABLE_MEET === 'true',
enableSms: env.ENABLE_SMS === 'true',
enablePayments: env.ENABLE_PAYMENTS === 'true',
enableSocial: env.ENABLE_SOCIAL === 'true',
enablePeople: env.ENABLE_PEOPLE === 'true',
enableAnalytics: env.ENABLE_ANALYTICS === 'true',
enableEvents: env.GANCIO_SYNC_ENABLED === 'true',
enableNewsletter: env.LISTMONK_SYNC_ENABLED === 'true',
navConfig: {
items: [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },

View File

@ -207,6 +207,11 @@ const envSchema = z.object({
// SMS Campaigns (Termux Android bridge)
ENABLE_SMS: z.string().default('false'),
// Social, People, Analytics (initial defaults; DB authoritative once admin saves)
ENABLE_SOCIAL: z.string().default('false'),
ENABLE_PEOPLE: z.string().default('false'),
ENABLE_ANALYTICS: z.string().default('false'),
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

@ -10,6 +10,8 @@ import InstanceListPage from '@/pages/InstanceListPage';
import CreateWizardPage from '@/pages/CreateWizardPage';
import InstanceDetailPage from '@/pages/InstanceDetailPage';
import RegisterInstancePage from '@/pages/RegisterInstancePage';
import AgentRegistrationsPage from '@/pages/AgentRegistrationsPage';
import InviteCodesPage from '@/pages/InviteCodesPage';
import BackupsPage from '@/pages/BackupsPage';
import AuditLogPage from '@/pages/AuditLogPage';
import SettingsPage from '@/pages/SettingsPage';
@ -58,6 +60,8 @@ export default function App() {
<Route path="instances/new" element={<CreateWizardPage />} />
<Route path="instances/register" element={<RegisterInstancePage />} />
<Route path="instances/:id" element={<InstanceDetailPage />} />
<Route path="agents/registrations" element={<AgentRegistrationsPage />} />
<Route path="agents/invite-codes" element={<InviteCodesPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="audit" element={<AuditLogPage />} />
<Route path="settings" element={<SettingsPage />} />

View File

@ -11,6 +11,8 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
ApiOutlined,
KeyOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth.store';
@ -42,6 +44,15 @@ export default function AppLayout() {
icon: <SaveOutlined />,
label: 'Backups',
},
{
key: '/app/agents',
icon: <ApiOutlined />,
label: 'Remote Agents',
children: [
{ key: '/app/agents/registrations', icon: <ApiOutlined />, label: 'Registrations' },
{ key: '/app/agents/invite-codes', icon: <KeyOutlined />, label: 'Invite Codes' },
],
},
{
key: '/app/audit',
icon: <AuditOutlined />,
@ -56,7 +67,13 @@ export default function AppLayout() {
// Use startsWith matching with longest-match preference so sub-routes
// like /app/instances/123 highlight the "Instances" menu item, not Dashboard.
const selectedKey = menuItems
// Flatten children for matching.
const allKeys = menuItems.flatMap((item) =>
'children' in item && item.children
? item.children.map((c) => ({ key: c.key as string }))
: [{ key: item.key as string }]
);
const selectedKey = allKeys
.filter((item) => location.pathname === item.key || location.pathname.startsWith(item.key + '/'))
.sort((a, b) => b.key.length - a.key.length)[0]?.key || '/app';

View File

@ -0,0 +1,158 @@
import { useState, useEffect, useCallback } from 'react';
import { Typography, Button, Table, Tag, Space, message, Card, Modal, Descriptions, Alert } from 'antd';
import { CheckOutlined, CloseOutlined, ReloadOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AgentRegistration } from '@/types/api';
const { Title } = Typography;
export default function AgentRegistrationsPage() {
const [registrations, setRegistrations] = useState<AgentRegistration[]>([]);
const [loading, setLoading] = useState(true);
const [detailModal, setDetailModal] = useState<AgentRegistration | null>(null);
const fetchRegistrations = useCallback(async () => {
try {
setLoading(true);
const { data } = await api.get('/api/agents/registrations');
setRegistrations(data);
} catch {
message.error('Failed to load registrations');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchRegistrations(); }, [fetchRegistrations]);
const handleApprove = async (id: string) => {
try {
await api.post(`/api/agents/registrations/${id}/approve`);
message.success('Registration approved — agent will receive certificates on next poll');
fetchRegistrations();
setDetailModal(null);
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } };
message.error(error?.response?.data?.message || 'Failed to approve');
}
};
const handleReject = async (id: string) => {
try {
await api.post(`/api/agents/registrations/${id}/reject`);
message.success('Registration rejected');
fetchRegistrations();
setDetailModal(null);
} catch {
message.error('Failed to reject');
}
};
const statusColor: Record<string, string> = {
PENDING: 'orange',
APPROVED: 'green',
REJECTED: 'red',
EXPIRED: 'default',
};
const columns = [
{ title: 'Slug', dataIndex: 'slug', key: 'slug' },
{ title: 'Domain', dataIndex: 'domain', key: 'domain' },
{ title: 'Agent URL', dataIndex: 'agentUrl', key: 'agentUrl' },
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: string) => <Tag color={statusColor[status]}>{status}</Tag>,
},
{
title: 'Submitted',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: AgentRegistration) => (
<Space>
<Button type="link" size="small" onClick={() => setDetailModal(record)}>Details</Button>
{record.status === 'PENDING' && (
<>
<Button type="primary" size="small" icon={<CheckOutlined />} onClick={() => handleApprove(record.id)}>
Approve
</Button>
<Button danger size="small" icon={<CloseOutlined />} onClick={() => handleReject(record.id)}>
Reject
</Button>
</>
)}
</Space>
),
},
];
const pendingCount = registrations.filter(r => r.status === 'PENDING').length;
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={3} style={{ margin: 0 }}>
Agent Registrations
{pendingCount > 0 && <Tag color="orange" style={{ marginLeft: 8 }}>{pendingCount} pending</Tag>}
</Title>
<Button icon={<ReloadOutlined />} onClick={fetchRegistrations}>Refresh</Button>
</div>
{pendingCount > 0 && (
<Alert
type="warning"
message={`${pendingCount} registration${pendingCount > 1 ? 's' : ''} awaiting approval`}
description="Review the agent details below and approve or reject the registration. Approved agents will receive mTLS certificates automatically."
showIcon
/>
)}
<Card>
<Table
columns={columns}
dataSource={registrations}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
</Card>
</Space>
<Modal
title={`Registration: ${detailModal?.slug}`}
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={detailModal?.status === 'PENDING' ? [
<Button key="reject" danger onClick={() => detailModal && handleReject(detailModal.id)}>Reject</Button>,
<Button key="approve" type="primary" onClick={() => detailModal && handleApprove(detailModal.id)}>Approve</Button>,
] : null}
width={600}
>
{detailModal && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Slug">{detailModal.slug}</Descriptions.Item>
<Descriptions.Item label="Name">{detailModal.name}</Descriptions.Item>
<Descriptions.Item label="Domain">{detailModal.domain}</Descriptions.Item>
<Descriptions.Item label="Agent URL">{detailModal.agentUrl}</Descriptions.Item>
<Descriptions.Item label="Base Path">{detailModal.basePath}</Descriptions.Item>
<Descriptions.Item label="Compose Project">{detailModal.composeProject}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={statusColor[detailModal.status]}>{detailModal.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Submitted">{new Date(detailModal.createdAt).toLocaleString()}</Descriptions.Item>
{detailModal.approvedAt && (
<Descriptions.Item label="Approved">{new Date(detailModal.approvedAt).toLocaleString()}</Descriptions.Item>
)}
</Descriptions>
)}
</Modal>
</div>
);
}

View File

@ -37,6 +37,7 @@ interface WizardData {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
jvbAdvertiseIp: string;
smtpHost: string;
smtpPort: number;
@ -66,6 +67,7 @@ const defaultData: WizardData = {
enableSms: false,
enableSocial: false,
enablePeople: false,
enableAnalytics: false,
jvbAdvertiseIp: '',
smtpHost: '',
smtpPort: 587,
@ -246,10 +248,10 @@ export default function CreateWizardPage() {
<span>Code Server, Gitea, n8n, Homepage, Excalidraw</span>
</Space>
</Card>
<Card size="small" title="Payments">
<Card size="small" title="Payments (Stripe)">
<Space>
<Switch checked={data.enablePayments} onChange={(v) => update({ enablePayments: v })} />
<span>Vaultwarden (secrets vault, future)</span>
<span>Products, donations, subscriptions, ticketed events</span>
</Space>
</Card>
<Card size="small" title="Video Conferencing (Jitsi Meet)">

View File

@ -405,6 +405,7 @@ export default function InstanceDetailPage() {
enableSms: instance.enableSms,
enableSocial: instance.enableSocial,
enablePeople: instance.enablePeople,
enableAnalytics: instance.enableAnalytics,
});
tunnelForm.setFieldsValue({
pangolinEndpoint: instance.pangolinEndpoint || '',
@ -492,7 +493,8 @@ export default function InstanceDetailPage() {
featureFlags.enableMeet !== instance.enableMeet ||
featureFlags.enableSms !== instance.enableSms ||
featureFlags.enableSocial !== instance.enableSocial ||
featureFlags.enablePeople !== instance.enablePeople
featureFlags.enablePeople !== instance.enablePeople ||
featureFlags.enableAnalytics !== instance.enableAnalytics
) : false;
if (loading || !instance) {
@ -821,7 +823,7 @@ export default function InstanceDetailPage() {
{isRegistered && (
<Alert
message="Features are read-only for external instances"
description="This instance was deployed outside the control panel. Feature toggles are informational only — changes must be made directly on the instance."
description="This instance was deployed outside the control panel. Feature toggles are synced automatically from the instance's .env file during health checks."
type="info"
showIcon
/>
@ -850,6 +852,19 @@ export default function InstanceDetailPage() {
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Payments (Stripe)</Typography.Text>
<br />
<Typography.Text type="secondary">Products, donations, subscriptions, ticketed events</Typography.Text>
</div>
<Switch
checked={featureFlags.enablePayments}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enablePayments: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Newsletter (Listmonk)</Typography.Text>
@ -876,6 +891,36 @@ export default function InstanceDetailPage() {
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>People CRM</Typography.Text>
<br />
<Typography.Text type="secondary">Unified people management, contact linking</Typography.Text>
</div>
<Switch
checked={featureFlags.enablePeople}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enablePeople: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Analytics & GeoIP</Typography.Text>
<br />
<Typography.Text type="secondary">Visitor geography tracking, user drill-down, unified dashboard</Typography.Text>
</div>
<Switch
checked={featureFlags.enableAnalytics}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
disabled={isRegistered}
/>
</div>
</Space>
</Card>
<Card title="Communication" size="small">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Chat (Rocket.Chat)</Typography.Text>
@ -888,6 +933,45 @@ export default function InstanceDetailPage() {
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Video Conferencing (Jitsi Meet)</Typography.Text>
<br />
<Typography.Text type="secondary">Self-hosted video calls (4 containers)</Typography.Text>
</div>
<Switch
checked={featureFlags.enableMeet}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableMeet: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>SMS Campaigns</Typography.Text>
<br />
<Typography.Text type="secondary">Termux Android bridge, bulk SMS outreach</Typography.Text>
</div>
<Switch
checked={featureFlags.enableSms}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableSms: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Social Connections</Typography.Text>
<br />
<Typography.Text type="secondary">Volunteer friendships, challenges, spotlights, referrals</Typography.Text>
</div>
<Switch
checked={featureFlags.enableSocial}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableSocial: v }))}
disabled={isRegistered}
/>
</div>
</Space>
</Card>
@ -897,7 +981,7 @@ export default function InstanceDetailPage() {
<div>
<Typography.Text strong>Monitoring</Typography.Text>
<br />
<Typography.Text type="secondary">Prometheus, Grafana, Alertmanager</Typography.Text>
<Typography.Text type="secondary">Prometheus, Grafana, Alertmanager, cAdvisor</Typography.Text>
</div>
<Switch
checked={featureFlags.enableMonitoring}
@ -918,84 +1002,6 @@ export default function InstanceDetailPage() {
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Payments</Typography.Text>
<br />
<Typography.Text type="secondary">Vaultwarden (secrets vault, future)</Typography.Text>
</div>
<Switch
checked={featureFlags.enablePayments}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enablePayments: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Video Conferencing (Jitsi Meet)</Typography.Text>
<br />
<Typography.Text type="secondary">Self-hosted video calls, Rocket.Chat integration (4 containers)</Typography.Text>
</div>
<Switch
checked={featureFlags.enableMeet}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableMeet: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>SMS Campaigns</Typography.Text>
<br />
<Typography.Text type="secondary">Termux-based SMS outreach (no additional containers)</Typography.Text>
</div>
<Switch
checked={featureFlags.enableSms}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableSms: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Social Connections</Typography.Text>
<br />
<Typography.Text type="secondary">Volunteer social features, friend connections</Typography.Text>
</div>
<Switch
checked={featureFlags.enableSocial}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableSocial: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>People CRM</Typography.Text>
<br />
<Typography.Text type="secondary">Unified people management, contact linking</Typography.Text>
</div>
<Switch
checked={featureFlags.enablePeople}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enablePeople: v }))}
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Analytics & GeoIP</Typography.Text>
<br />
<Typography.Text type="secondary">Unified analytics, visitor geography, user drill-down</Typography.Text>
</div>
<Switch
checked={featureFlags.enableAnalytics}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
disabled={isRegistered}
/>
</div>
</Space>
</Card>
@ -1015,6 +1021,7 @@ export default function InstanceDetailPage() {
enableSms: instance.enableSms,
enableSocial: instance.enableSocial,
enablePeople: instance.enablePeople,
enableAnalytics: instance.enableAnalytics,
});
}}
disabled={!hasFeatureChanges}

View File

@ -112,6 +112,7 @@ export default function InstanceListPage() {
<Space size="small">
<a onClick={() => navigate(`/app/instances/${record.id}`)}>{name}</a>
{record.isRegistered && <Tag color="purple">External</Tag>}
{record.isRemote && <Tag color="cyan">Remote</Tag>}
</Space>
),
},

View File

@ -0,0 +1,138 @@
import { useState, useEffect, useCallback } from 'react';
import { Typography, Button, Table, Tag, Space, message, Popconfirm, Card, Alert } from 'antd';
import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AgentInviteCode } from '@/types/api';
const { Title, Text } = Typography;
export default function InviteCodesPage() {
const [codes, setCodes] = useState<AgentInviteCode[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const fetchCodes = useCallback(async () => {
try {
setLoading(true);
const { data } = await api.get('/api/invite-codes');
setCodes(data.data || []);
} catch {
message.error('Failed to load invite codes');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchCodes(); }, [fetchCodes]);
const handleCreate = async () => {
try {
setCreating(true);
const { data } = await api.post('/api/invite-codes');
message.success(`Invite code created: ${data.code}`);
fetchCodes();
} catch {
message.error('Failed to create invite code');
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
try {
await api.delete(`/api/invite-codes/${id}`);
message.success('Invite code revoked');
fetchCodes();
} catch {
message.error('Failed to revoke invite code');
}
};
const copyCode = (code: string) => {
navigator.clipboard.writeText(code);
message.success('Code copied to clipboard');
};
const columns = [
{
title: 'Code',
dataIndex: 'code',
key: 'code',
render: (code: string) => (
<Space>
<Text code style={{ fontSize: 16 }}>{code}</Text>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => copyCode(code)} />
</Space>
),
},
{
title: 'Status',
key: 'status',
render: (_: unknown, record: AgentInviteCode) => {
if (record.usedAt) return <Tag color="green">Used</Tag>;
if (new Date(record.expiresAt) < new Date()) return <Tag color="red">Expired</Tag>;
return <Tag color="blue">Active</Tag>;
},
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: 'Expires',
dataIndex: 'expiresAt',
key: 'expiresAt',
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: 'Created By',
dataIndex: 'createdBy',
key: 'createdBy',
render: (user: AgentInviteCode['createdBy']) => user?.name || user?.email || '-',
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: AgentInviteCode) => {
if (record.usedAt) return null;
return (
<Popconfirm title="Revoke this invite code?" onConfirm={() => handleRevoke(record.id)}>
<Button type="text" danger icon={<DeleteOutlined />} size="small">Revoke</Button>
</Popconfirm>
);
},
},
];
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={3} style={{ margin: 0 }}>Agent Invite Codes</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
Generate Code
</Button>
</div>
<Alert
type="info"
message="Invite codes are used by remote CML instances during setup to register with this control panel."
description="Each code is single-use and expires after 24 hours. Share the code with the remote operator who will enter it during config.sh setup."
showIcon
/>
<Card>
<Table
columns={columns}
dataSource={codes}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
</Card>
</Space>
</div>
);
}

View File

@ -21,8 +21,14 @@ export interface Instance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
jvbAdvertiseIp?: string;
isRegistered: boolean;
isRemote: boolean;
agentUrl?: string;
agentFingerprint?: string;
agentVersion?: string;
agentLastSeen?: string;
adminEmail: string;
pangolinEndpoint?: string;
pangolinSiteId?: string;
@ -95,6 +101,7 @@ export interface DiscoveredInstance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
emailTestMode: boolean;
source: 'parent' | 'docker';
isRunning: boolean;
@ -202,3 +209,41 @@ export interface AuditLogEntry {
user?: { id: string; email: string; name: string } | null;
instance?: { id: string; name: string; slug: string } | null;
}
// ─── Remote Agent Types ─────────────────────────────────────────────
export interface AgentRegistration {
id: string;
inviteCodeId: string;
slug: string;
name: string;
domain: string;
agentUrl: string;
basePath: string;
composeProject: string;
metadata?: Record<string, unknown>;
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXPIRED';
instanceId?: string;
approvedById?: string;
approvedAt?: string;
rejectedAt?: string;
createdAt: string;
}
export interface AgentInviteCode {
id: string;
code: string;
createdById: string;
usedById?: string;
expiresAt: string;
usedAt?: string;
createdAt: string;
createdBy?: { id: string; name: string; email: string };
}
export interface AgentStatus {
reachable: boolean;
version?: string;
uptime?: number;
error?: string;
}

View File

@ -0,0 +1,17 @@
FROM node:20-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc
FROM node:20-alpine
RUN apk add --no-cache docker-cli docker-cli-compose git rsync
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist/ ./dist/
EXPOSE 7443
CMD ["node", "dist/server.js"]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"name": "ccp-agent",
"version": "1.0.0",
"description": "Changemaker Control Panel — Remote Agent",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"winston": "^3.17.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View File

@ -0,0 +1,43 @@
import 'dotenv/config';
import { z } from 'zod';
const envSchema = z.object({
// Agent server
AGENT_PORT: z.coerce.number().default(7443),
AGENT_LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// TLS certificates (required once approved)
AGENT_CERT_PATH: z.string().default('/etc/ccp-agent/agent.pem'),
AGENT_KEY_PATH: z.string().default('/etc/ccp-agent/agent.key'),
AGENT_CA_CERT_PATH: z.string().default('/etc/ccp-agent/ca.pem'),
// Allowed CCP fingerprints (comma-separated SHA-256 hex)
ALLOWED_CCP_FINGERPRINTS: z.string().default(''),
// Data directory (registry.json lives here)
AGENT_DATA_DIR: z.string().default('/var/lib/ccp-agent'),
// Phone-home registration (set during initial setup, cleared after approval)
CCP_URL: z.string().default(''),
CCP_INVITE_CODE: z.string().default(''),
CCP_AGENT_URL: z.string().default(''), // How CCP can reach this agent
// Instance info (for phone-home registration)
INSTANCE_SLUG: z.string().default(''),
INSTANCE_DOMAIN: z.string().default(''),
INSTANCE_BASE_PATH: z.string().default(''),
});
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
for (const [key, errors] of Object.entries(result.error.flatten().fieldErrors)) {
console.error(` ${key}: ${errors?.join(', ')}`);
}
process.exit(1);
}
return result.data;
}
export const env = validateEnv();

View File

@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../utils/logger';
export class AgentError extends Error {
constructor(public statusCode: number, message: string, public code?: string) {
super(message);
this.name = 'AgentError';
}
}
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof AgentError) {
res.status(err.statusCode).json({
error: err.code || 'AGENT_ERROR',
message: err.message,
});
return;
}
logger.error(`Unhandled error: ${err.message}`);
res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'An internal error occurred',
});
}

View File

@ -0,0 +1,71 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { Request, Response, NextFunction } from 'express';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import type { TLSSocket } from 'tls';
/**
* Load allowed fingerprints from env var or from the auto-generated config file.
* The config file is written during phone-home cert installation.
*/
function loadAllowedFingerprints(): string[] {
// First check env var
if (env.ALLOWED_CCP_FINGERPRINTS) {
return env.ALLOWED_CCP_FINGERPRINTS.split(',').map((f) => f.trim().toLowerCase());
}
// Fall back to the auto-generated fingerprint file from phone-home registration
try {
const configPath = path.join(env.AGENT_DATA_DIR, 'ccp-fingerprint');
const fingerprint = fs.readFileSync(configPath, 'utf-8').trim().toLowerCase();
if (fingerprint) return [fingerprint];
} catch {
// No fingerprint file — fingerprint pinning not available
}
return [];
}
// Cache the fingerprints at startup — reload requires restart
const allowedFingerprints = loadAllowedFingerprints();
/**
* mTLS authentication middleware.
* Verifies that the connecting client presented a valid certificate
* signed by the trusted CA, and checks against allowed fingerprints.
*/
export function mtlsAuth(req: Request, res: Response, next: NextFunction) {
const socket = req.socket as TLSSocket;
// Check that the client presented a certificate and it was authorized by the TLS layer
if (!socket.authorized) {
const authError = socket.authorizationError;
logger.warn(`[mtls] Client certificate rejected: ${authError}`);
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid client certificate' });
return;
}
const peerCert = socket.getPeerCertificate();
if (!peerCert || !peerCert.raw) {
logger.warn('[mtls] No peer certificate presented');
res.status(401).json({ error: 'UNAUTHORIZED', message: 'No client certificate' });
return;
}
// SECURITY: Check fingerprint against allowed list (env var or auto-generated file)
if (allowedFingerprints.length > 0) {
const fingerprint = crypto.createHash('sha256').update(peerCert.raw).digest('hex');
if (!allowedFingerprints.includes(fingerprint)) {
logger.warn(`[mtls] Client fingerprint ${fingerprint.substring(0, 16)}... not in allowed list`);
res.status(403).json({ error: 'FORBIDDEN', message: 'Client certificate not authorized' });
return;
}
} else {
// No fingerprint pinning configured — log a warning but allow (CA validation is still enforced)
logger.warn('[mtls] No fingerprint pinning configured — relying on CA chain validation only');
}
next();
}

View File

@ -0,0 +1,105 @@
import { Router, Request, Response } from 'express';
import { param } from '../utils/params';
import fs from 'fs/promises';
import path from 'path';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import * as docker from '../services/docker.service';
import { getSlugEntry } from '../services/registry.service';
import { env } from '../config/env';
import { logger } from '../utils/logger';
const exec = promisify(execCb);
const router = Router();
// POST /instance/:slug/backup — Run pg_dump + tar uploads → return backup info
router.post('/instance/:slug/backup', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = path.join(env.AGENT_DATA_DIR, 'backups', param(req, 'slug'), timestamp);
await fs.mkdir(backupDir, { recursive: true });
const { pgPassword } = req.body;
try {
// 1. pg_dump
const dumpFile = path.join(backupDir, 'database.sql');
const dump = await docker.composeExec(
entry.basePath, entry.composeProject,
'v2-postgres',
'pg_dump -U changemaker -d changemaker',
300_000,
pgPassword ? { PGPASSWORD: pgPassword } : undefined
);
await fs.writeFile(dumpFile, dump, 'utf-8');
// Gzip the dump
await exec(`gzip '${dumpFile}'`, { timeout: 120_000 });
// 2. Tar uploads if exists
const uploadsDir = path.join(entry.basePath, 'uploads');
let hasUploads = false;
try {
await fs.access(uploadsDir);
hasUploads = true;
} catch { /* no uploads dir */ }
if (hasUploads) {
await exec(
`tar -czf '${path.join(backupDir, 'uploads.tar.gz')}' -C '${entry.basePath}' uploads`,
{ timeout: 300_000 }
);
}
// 3. Create final archive
const archiveName = `backup-${param(req, 'slug')}-${timestamp}.tar.gz`;
const archivePath = path.join(env.AGENT_DATA_DIR, 'backups', archiveName);
await exec(
`tar -czf '${archivePath}' -C '${path.dirname(backupDir)}' '${timestamp}'`,
{ timeout: 300_000 }
);
// Clean up temp dir
await fs.rm(backupDir, { recursive: true, force: true });
const stats = await fs.stat(archivePath);
const backupId = timestamp;
logger.info(`[backup] Created backup for ${param(req, 'slug')}: ${archivePath} (${stats.size} bytes)`);
res.json({
backupId,
archivePath,
sizeBytes: stats.size,
timestamp,
});
} catch (err) {
// Clean up on failure
try { await fs.rm(backupDir, { recursive: true, force: true }); } catch { /* ignore */ }
throw err;
}
});
// GET /instance/:slug/backup/:id/download — Stream backup archive
router.get('/instance/:slug/backup/:id/download', async (req: Request, res: Response) => {
const archiveName = `backup-${param(req, 'slug')}-${param(req, 'id')}.tar.gz`;
const archivePath = path.join(env.AGENT_DATA_DIR, 'backups', archiveName);
try {
await fs.access(archivePath);
} catch {
res.status(404).json({ error: 'NOT_FOUND', message: 'Backup archive not found' });
return;
}
const stats = await fs.stat(archivePath);
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Length', stats.size);
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
const { createReadStream } = await import('fs');
const stream = createReadStream(archivePath);
stream.pipe(res);
});
export default router;

View File

@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import * as docker from '../services/docker.service';
import { getSlugEntry } from '../services/registry.service';
import { param } from '../utils/params';
const router = Router();
// GET /instance/:slug/ps
router.get('/instance/:slug/ps', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const containers = await docker.composePs(entry.basePath, entry.composeProject);
res.json(containers);
});
// GET /instance/:slug/logs
router.get('/instance/:slug/logs', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const service = req.query.service as string | undefined;
const tail = req.query.tail ? Number(req.query.tail) : 200;
const since = req.query.since as string | undefined;
const logs = await docker.composeLogs(entry.basePath, entry.composeProject, service, tail, since);
res.json(logs);
});
// POST /instance/:slug/up
router.post('/instance/:slug/up', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const services = req.body?.services as string[] | undefined;
const result = await docker.composeUp(entry.basePath, entry.composeProject, services);
res.json(result);
});
// POST /instance/:slug/stop
router.post('/instance/:slug/stop', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const result = await docker.composeStop(entry.basePath, entry.composeProject);
res.json(result);
});
// POST /instance/:slug/restart
router.post('/instance/:slug/restart', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const service = req.body?.service as string | undefined;
const result = await docker.composeRestart(entry.basePath, entry.composeProject, service);
res.json(result);
});
// POST /instance/:slug/down
router.post('/instance/:slug/down', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const removeVolumes = req.body?.removeVolumes === true;
const result = await docker.composeDown(entry.basePath, entry.composeProject, removeVolumes);
res.json(result);
});
// POST /instance/:slug/pull
router.post('/instance/:slug/pull', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const result = await docker.composePull(entry.basePath, entry.composeProject);
res.json(result);
});
// POST /instance/:slug/build
router.post('/instance/:slug/build', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const result = await docker.composeBuild(entry.basePath, entry.composeProject);
res.json(result);
});
// POST /instance/:slug/exec
router.post('/instance/:slug/exec', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const { service, command, envVars } = req.body;
if (!service || !command) {
res.status(400).json({ error: 'VALIDATION', message: 'service and command are required' });
return;
}
// SECURITY: Reject shell metacharacters entirely — prevents `;`, `&&`, `|`, `$()`, backticks
const SHELL_META = /[;&|`$(){}!><\n\r]/;
if (SHELL_META.test(command)) {
res.status(403).json({ error: 'FORBIDDEN', message: 'Command contains disallowed characters' });
return;
}
// Command allowlist — only allow known safe command prefixes
const allowedPatterns = [
/^pg_dump\b/,
/^npx\s+prisma\b/,
/^cat\s/,
/^ls\b/,
/^echo\b/,
];
if (!allowedPatterns.some((p) => p.test(command))) {
res.status(403).json({ error: 'FORBIDDEN', message: 'Command not in allowlist' });
return;
}
const result = await docker.composeExec(entry.basePath, entry.composeProject, service, command, undefined, envVars);
res.json(result);
});
export default router;

View File

@ -0,0 +1,51 @@
import { Router, Request, Response } from 'express';
import { getSlugEntry } from '../services/registry.service';
import { param } from '../utils/params';
import * as fileService from '../services/file.service';
const router = Router();
// GET /instance/:slug/env — Read .env as key/value map
router.get('/instance/:slug/env', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const envVars = await fileService.readEnvFile(entry.basePath);
res.json(envVars);
});
// POST /instance/:slug/files — Write rendered template files
router.post('/instance/:slug/files', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const { files } = req.body;
if (!Array.isArray(files)) {
res.status(400).json({ error: 'VALIDATION', message: 'files array required' });
return;
}
await fileService.writeFiles(entry.basePath, files);
res.json({ written: files.length });
});
// POST /instance/:slug/mkdir — Create directory
router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const { path: dirPath } = req.body;
if (!dirPath) {
res.status(400).json({ error: 'VALIDATION', message: 'path required' });
return;
}
await fileService.mkdirp(entry.basePath, dirPath);
res.json({ created: dirPath });
});
// POST /instance/:slug/clone-source — Git clone CML source
router.post('/instance/:slug/clone-source', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const { gitRepo, gitBranch, excludes } = req.body;
if (!gitRepo || !gitBranch) {
res.status(400).json({ error: 'VALIDATION', message: 'gitRepo and gitBranch required' });
return;
}
await fileService.cloneSource(entry.basePath, gitRepo, gitBranch, excludes);
res.json({ cloned: true });
});
export default router;

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
const router = Router();
const startedAt = Date.now();
const VERSION = '1.0.0';
router.get('/health', (_req, res) => {
res.json({
status: 'ok',
version: VERSION,
uptime: Math.floor((Date.now() - startedAt) / 1000),
});
});
export default router;

View File

@ -0,0 +1,30 @@
import { Router, Request, Response } from 'express';
import { param } from '../utils/params';
import { registerSlug, unregisterSlug, listSlugs } from '../services/registry.service';
const router = Router();
// POST /instances/register — Register a slug→basePath mapping
router.post('/instances/register', async (req: Request, res: Response) => {
const { slug, basePath, composeProject } = req.body;
if (!slug || !basePath || !composeProject) {
res.status(400).json({ error: 'VALIDATION', message: 'slug, basePath, and composeProject required' });
return;
}
await registerSlug(slug, basePath, composeProject);
res.json({ registered: slug });
});
// DELETE /instances/:slug — Unregister slug
router.delete('/instances/:slug', async (req: Request, res: Response) => {
await unregisterSlug(param(req, 'slug'));
res.json({ unregistered: param(req, 'slug') });
});
// GET /instances — List all managed slugs
router.get('/instances', async (_req: Request, res: Response) => {
const slugs = await listSlugs();
res.json(slugs);
});
export default router;

View File

@ -0,0 +1,79 @@
import { Router, Request, Response } from 'express';
import { param } from '../utils/params';
import { execFile } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import { getSlugEntry } from '../services/registry.service';
import { logger } from '../utils/logger';
const execFileAsync = promisify(execFile);
const router = Router();
/** Validate a git branch name — prevent shell injection. */
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
// POST /instance/:slug/upgrade/start — Run upgrade.sh
router.post('/instance/:slug/upgrade/start', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const { skipBackup, useRegistry, branch } = req.body || {};
// SECURITY: Validate branch name to prevent injection
if (branch && !SAFE_BRANCH.test(branch)) {
res.status(400).json({ error: 'VALIDATION', message: 'Invalid branch name' });
return;
}
const scriptPath = path.join(entry.basePath, 'scripts', 'upgrade.sh');
try {
await fs.access(scriptPath);
} catch {
res.status(400).json({ error: 'NOT_FOUND', message: 'upgrade.sh not found' });
return;
}
// SECURITY: Use execFile with args array — no shell interpolation
const args = ['--api-mode', '--force'];
if (skipBackup) args.push('--skip-backup');
if (useRegistry) args.push('--use-registry');
if (branch) args.push('--branch', branch);
// Fire-and-forget — CCP polls progress
execFileAsync('bash', [scriptPath, ...args], {
cwd: entry.basePath,
timeout: 600_000,
maxBuffer: 10 * 1024 * 1024,
}).catch((err) => {
logger.error(`[upgrade] ${param(req, 'slug')} failed: ${(err as Error).message}`);
});
res.json({ started: true });
});
// GET /instance/:slug/upgrade/progress — Read progress.json
router.get('/instance/:slug/upgrade/progress', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');
try {
const content = await fs.readFile(progressPath, 'utf-8');
res.json(JSON.parse(content));
} catch {
res.json({ phase: 0, percentage: 0, message: 'Waiting for upgrade to start...' });
}
});
// GET /instance/:slug/upgrade/result — Read result.json
router.get('/instance/:slug/upgrade/result', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');
try {
const content = await fs.readFile(resultPath, 'utf-8');
res.json(JSON.parse(content));
} catch {
res.status(404).json({ error: 'NOT_FOUND', message: 'No upgrade result available' });
}
});
export default router;

View File

@ -0,0 +1,159 @@
import 'express-async-errors';
import express from 'express';
import https from 'https';
import http from 'http';
import fs from 'fs';
import { env } from './config/env';
import { logger } from './utils/logger';
import { mtlsAuth } from './middleware/mtls-auth';
import { errorHandler } from './middleware/error-handler';
import healthRoutes from './routes/health.routes';
import composeRoutes from './routes/compose.routes';
import filesRoutes from './routes/files.routes';
import registryRoutes from './routes/registry.routes';
import backupRoutes from './routes/backup.routes';
import upgradeRoutes from './routes/upgrade.routes';
const app = express();
// Parse JSON bodies (up to 50MB for template file uploads)
app.use(express.json({ limit: '50mb' }));
// Health endpoint is always accessible (no mTLS required)
app.use(healthRoutes);
// All other routes require mTLS authentication
function hasCerts(): boolean {
try {
fs.accessSync(env.AGENT_CERT_PATH);
fs.accessSync(env.AGENT_KEY_PATH);
fs.accessSync(env.AGENT_CA_CERT_PATH);
return true;
} catch {
return false;
}
}
if (hasCerts()) {
// mTLS mode — certificates are installed
const tlsOptions: https.ServerOptions = {
key: fs.readFileSync(env.AGENT_KEY_PATH),
cert: fs.readFileSync(env.AGENT_CERT_PATH),
ca: fs.readFileSync(env.AGENT_CA_CERT_PATH),
requestCert: true,
rejectUnauthorized: true,
};
app.use(mtlsAuth);
app.use(composeRoutes);
app.use(filesRoutes);
app.use(registryRoutes);
app.use(backupRoutes);
app.use(upgradeRoutes);
app.use(errorHandler);
const server = https.createServer(tlsOptions, app);
server.listen(env.AGENT_PORT, () => {
logger.info(`CCP Agent (mTLS) listening on port ${env.AGENT_PORT}`);
});
} else {
// Pre-approval mode — start HTTP, only health + phone-home polling
logger.info('No certificates found — starting in phone-home registration mode');
app.use(errorHandler);
const server = http.createServer(app);
server.listen(env.AGENT_PORT, () => {
logger.info(`CCP Agent (registration mode) listening on port ${env.AGENT_PORT}`);
});
// Start phone-home polling if CCP_URL and CCP_INVITE_CODE are set
if (env.CCP_URL && env.CCP_INVITE_CODE) {
startPhoneHome();
}
}
/**
* Phone-home registration flow:
* 1. POST to CCP with invite code + instance metadata
* 2. Poll CCP every 30s until approved
* 3. On approval, save certs and restart with mTLS
*/
async function startPhoneHome() {
logger.info(`[phone-home] Registering with CCP at ${env.CCP_URL}...`);
// Step 1: Send registration request
try {
const response = await fetch(`${env.CCP_URL}/api/agents/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inviteCode: env.CCP_INVITE_CODE,
slug: env.INSTANCE_SLUG,
name: env.INSTANCE_SLUG,
domain: env.INSTANCE_DOMAIN,
agentUrl: env.CCP_AGENT_URL,
basePath: env.INSTANCE_BASE_PATH,
}),
});
if (!response.ok) {
const err = await response.text();
logger.error(`[phone-home] Registration failed: ${response.status} ${err}`);
return;
}
const result = await response.json() as { registrationId: string };
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
// Step 2: Poll for approval
const pollInterval = setInterval(async () => {
try {
const pollResp = await fetch(
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
);
if (!pollResp.ok) return;
const pollData = await pollResp.json() as {
status: string;
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
};
if (pollData.status === 'APPROVED' && pollData.certBundle) {
clearInterval(pollInterval);
logger.info('[phone-home] Approved! Saving certificates...');
// Save certs
const fsp = await import('fs/promises');
const pathMod = await import('path');
await fsp.mkdir(pathMod.dirname(env.AGENT_CERT_PATH), { recursive: true });
await fsp.writeFile(env.AGENT_CERT_PATH, pollData.certBundle.agentCertPem);
await fsp.writeFile(env.AGENT_KEY_PATH, pollData.certBundle.agentKeyPem);
await fsp.writeFile(env.AGENT_CA_CERT_PATH, pollData.certBundle.caCertPem);
// SECURITY: Write the CCP fingerprint to a config file so the agent
// can verify the CCP's identity on subsequent connections.
if (pollData.certBundle.ccpFingerprint) {
const configPath = pathMod.join(env.AGENT_DATA_DIR, 'ccp-fingerprint');
await fsp.mkdir(env.AGENT_DATA_DIR, { recursive: true });
await fsp.writeFile(configPath, pollData.certBundle.ccpFingerprint);
logger.info(`[phone-home] CCP fingerprint saved: ${pollData.certBundle.ccpFingerprint.substring(0, 16)}...`);
}
logger.info('[phone-home] Certificates saved. Restarting with mTLS...');
// Exit so Docker restart policy brings us back with certs
process.exit(0);
} else if (pollData.status === 'REJECTED') {
clearInterval(pollInterval);
logger.error('[phone-home] Registration was rejected by CCP admin');
}
} catch (err) {
logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
}
}, 30_000);
} catch (err) {
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
}
}

View File

@ -0,0 +1,134 @@
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import { logger } from '../utils/logger';
const exec = promisify(execCb);
const EXEC_TIMEOUT = 120_000;
function validateName(name: string, label: string): string {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid ${label}: ${name}`);
}
return name;
}
function validateDuration(value: string): string {
if (!/^\d+[smhd]$/.test(value)) throw new Error(`Invalid duration: ${value}`);
return value;
}
function validateTail(value: number): number {
return Math.floor(Math.max(1, Math.min(value, 5000)));
}
export interface ContainerInfo {
name: string;
service: string;
status: string;
state: string;
health: string;
ports: string;
createdAt: string;
exitCode: number;
}
async function execCmd(command: string, cwd: string, timeoutMs = EXEC_TIMEOUT) {
logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`);
try {
return await exec(command, {
cwd,
timeout: timeoutMs,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, COMPOSE_ANSI: 'never' },
});
} catch (err: unknown) {
const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
if (error.killed) throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`);
throw new Error(`Command failed: ${command}\n${error.stderr || error.message}`);
}
}
function composeCmd(project: string): string {
return `docker compose -p ${validateName(project, 'project')}`;
}
export async function composeUp(projectDir: string, project: string, services?: string[]) {
const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : '';
const orphanFlag = services?.length ? '' : ' --remove-orphans';
const { stdout, stderr } = await execCmd(`${composeCmd(project)} up -d${orphanFlag}${svc}`, projectDir);
return stdout || stderr;
}
export async function composeDown(projectDir: string, project: string, removeVolumes = false) {
const flags = removeVolumes ? ' -v' : '';
const { stdout, stderr } = await execCmd(`${composeCmd(project)} down${flags}`, projectDir);
return stdout || stderr;
}
export async function composeStop(projectDir: string, project: string) {
const { stdout, stderr } = await execCmd(`${composeCmd(project)} stop`, projectDir);
return stdout || stderr;
}
export async function composeRestart(projectDir: string, project: string, service?: string) {
const svc = service ? ` ${validateName(service, 'service')}` : '';
const { stdout, stderr } = await execCmd(`${composeCmd(project)} restart${svc}`, projectDir);
return stdout || stderr;
}
export async function composePull(projectDir: string, project: string) {
const { stdout, stderr } = await execCmd(`${composeCmd(project)} pull`, projectDir, 300_000);
return stdout || stderr;
}
export async function composeBuild(projectDir: string, project: string) {
const { stdout, stderr } = await execCmd(`${composeCmd(project)} build`, projectDir, 600_000);
return stdout || stderr;
}
export async function composePs(projectDir: string, project: string): Promise<ContainerInfo[]> {
const { stdout } = await execCmd(`${composeCmd(project)} ps --format json`, projectDir);
if (!stdout.trim()) return [];
const containers: ContainerInfo[] = [];
for (const line of stdout.trim().split('\n')) {
if (!line.trim()) continue;
try {
const raw = JSON.parse(line);
containers.push({
name: raw.Name || raw.name || '',
service: raw.Service || raw.service || '',
status: raw.Status || raw.status || '',
state: raw.State || raw.state || '',
health: raw.Health || raw.health || '',
ports: raw.Ports || raw.ports || '',
createdAt: raw.CreatedAt || raw.created_at || '',
exitCode: raw.ExitCode ?? raw.exit_code ?? 0,
});
} catch { /* skip */ }
}
return containers;
}
export async function composeLogs(projectDir: string, project: string, service?: string, tail = 200, since?: string) {
const parts = [composeCmd(project), 'logs', '--no-color'];
if (tail > 0) parts.push(`--tail=${validateTail(tail)}`);
if (since) parts.push(`--since=${validateDuration(since)}`);
if (service) parts.push(validateName(service, 'service'));
const { stdout, stderr } = await execCmd(parts.join(' '), projectDir);
return stdout || stderr;
}
export async function composeExec(
projectDir: string, project: string, service: string,
command: string, timeoutMs = EXEC_TIMEOUT, envVars?: Record<string, string>
) {
const envFlags = envVars
? Object.entries(envVars).map(([k, v]) => `-e ${k}='${v.replace(/'/g, "'\\''")}'`).join(' ') + ' '
: '';
const { stdout, stderr } = await execCmd(
`${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`,
projectDir, timeoutMs
);
return stdout || stderr;
}

View File

@ -0,0 +1,104 @@
import fs from 'fs/promises';
import path from 'path';
import { parse as parseDotenv } from 'dotenv';
import { AgentError } from '../middleware/error-handler';
import { logger } from '../utils/logger';
/**
* Validate that a resolved path is within the allowed basePath.
* Prevents path traversal attacks.
*/
function assertWithin(filePath: string, basePath: string): void {
const resolvedFile = path.resolve(filePath);
const resolvedBase = path.resolve(basePath);
if (!resolvedFile.startsWith(resolvedBase + '/') && resolvedFile !== resolvedBase) {
throw new AgentError(403, `Path ${filePath} is outside allowed directory`, 'PATH_TRAVERSAL');
}
}
export async function readEnvFile(basePath: string): Promise<Record<string, string>> {
const envPath = path.join(basePath, '.env');
const content = await fs.readFile(envPath, 'utf-8');
return parseDotenv(Buffer.from(content));
}
export async function writeFiles(
basePath: string,
files: Array<{ relativePath: string; content: string }>
): Promise<void> {
for (const file of files) {
const filePath = path.join(basePath, file.relativePath);
assertWithin(filePath, basePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, file.content, 'utf-8');
logger.debug(`[files] Wrote ${filePath}`);
}
}
export async function mkdirp(basePath: string, relativePath: string): Promise<void> {
const dirPath = path.join(basePath, relativePath);
assertWithin(dirPath, basePath);
await fs.mkdir(dirPath, { recursive: true });
}
/** Validate git repo URL and branch name to prevent shell injection. */
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
const SAFE_REPO = /^[a-zA-Z0-9@:._\/-]+$/;
const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
export async function cloneSource(
basePath: string,
gitRepo: string,
gitBranch: string,
excludes?: string[]
): Promise<void> {
// SECURITY: Validate inputs before any shell execution
if (!SAFE_REPO.test(gitRepo)) {
throw new AgentError(400, 'Invalid git repository URL', 'VALIDATION');
}
if (!SAFE_BRANCH.test(gitBranch)) {
throw new AgentError(400, 'Invalid git branch name', 'VALIDATION');
}
const { execFile } = await import('child_process');
const { promisify } = await import('util');
const execFileAsync = promisify(execFile);
// Ensure base directory exists
await fs.mkdir(basePath, { recursive: true });
// Clone into a temp directory first, then move contents
const tmpDir = `${basePath}.tmp-${Date.now()}`;
try {
// SECURITY: Use execFile with args array — no shell interpolation
await execFileAsync('git', ['clone', '--branch', gitBranch, '--depth', '1', gitRepo, tmpDir], {
timeout: 300_000,
});
// Remove git metadata and excluded directories
const defaultExcludes = excludes || [
'.git', 'node_modules', 'changemaker-control-panel', '.claude',
'api/dist', 'admin/dist',
];
for (const exclude of defaultExcludes) {
// SECURITY: Validate each exclude entry
if (!SAFE_EXCLUDE.test(exclude)) continue;
const excludePath = path.join(tmpDir, exclude);
// SECURITY: Verify exclude path is within tmpDir
if (!path.resolve(excludePath).startsWith(path.resolve(tmpDir) + '/')) continue;
try {
await fs.rm(excludePath, { recursive: true, force: true });
} catch { /* ignore if doesn't exist */ }
}
// Move contents to basePath using execFile (no shell)
await execFileAsync('rsync', ['-a', `${tmpDir}/`, `${basePath}/`], { timeout: 120_000 });
await fs.rm(tmpDir, { recursive: true, force: true });
logger.info(`[files] Cloned ${gitRepo}@${gitBranch}${basePath}`);
} catch (err) {
// Clean up temp dir on failure
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
throw err;
}
}

View File

@ -0,0 +1,69 @@
import fs from 'fs/promises';
import path from 'path';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { AgentError } from '../middleware/error-handler';
interface SlugEntry {
basePath: string;
composeProject: string;
registeredAt: string;
}
type Registry = Record<string, SlugEntry>;
const registryPath = () => path.join(env.AGENT_DATA_DIR, 'registry.json');
let cache: Registry | null = null;
async function loadRegistry(): Promise<Registry> {
if (cache) return cache;
try {
const data = await fs.readFile(registryPath(), 'utf-8');
cache = JSON.parse(data) as Registry;
return cache;
} catch {
cache = {};
return cache;
}
}
async function saveRegistry(registry: Registry): Promise<void> {
await fs.mkdir(env.AGENT_DATA_DIR, { recursive: true });
await fs.writeFile(registryPath(), JSON.stringify(registry, null, 2), 'utf-8');
cache = registry;
}
export async function registerSlug(slug: string, basePath: string, composeProject: string): Promise<void> {
const registry = await loadRegistry();
registry[slug] = {
basePath,
composeProject,
registeredAt: new Date().toISOString(),
};
await saveRegistry(registry);
logger.info(`[registry] Registered slug ${slug}${basePath} (project: ${composeProject})`);
}
export async function unregisterSlug(slug: string): Promise<void> {
const registry = await loadRegistry();
if (!registry[slug]) {
throw new AgentError(404, `Slug ${slug} not registered`);
}
delete registry[slug];
await saveRegistry(registry);
logger.info(`[registry] Unregistered slug ${slug}`);
}
export async function getSlugEntry(slug: string): Promise<SlugEntry> {
const registry = await loadRegistry();
const entry = registry[slug];
if (!entry) {
throw new AgentError(404, `Slug ${slug} not registered`, 'SLUG_NOT_FOUND');
}
return entry;
}
export async function listSlugs(): Promise<Record<string, SlugEntry>> {
return loadRegistry();
}

View File

@ -0,0 +1,12 @@
import winston from 'winston';
import { env } from '../config/env';
export const logger = winston.createLogger({
level: env.AGENT_LOG_LEVEL,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`)
),
transports: [new winston.transports.Console()],
});

View File

@ -0,0 +1,11 @@
import { Request } from 'express';
/**
* Extract a route parameter as a string.
* Express 5 types params as string | string[]; this helper narrows it.
*/
export function param(req: Request, name: string): string {
const val = req.params[name];
if (Array.isArray(val)) return val[0];
return val;
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "enable_analytics" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,111 @@
-- CreateEnum
CREATE TYPE "AgentRegistrationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'EXPIRED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_CONNECT';
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_REGISTER';
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_APPROVE';
ALTER TYPE "AuditAction" ADD VALUE 'AGENT_REJECT';
ALTER TYPE "AuditAction" ADD VALUE 'INVITE_CREATE';
ALTER TYPE "AuditAction" ADD VALUE 'INVITE_REVOKE';
ALTER TYPE "AuditAction" ADD VALUE 'CERT_ISSUE';
ALTER TYPE "AuditAction" ADD VALUE 'CERT_REVOKE';
-- AlterTable
ALTER TABLE "instances" ADD COLUMN "agent_fingerprint" TEXT,
ADD COLUMN "agent_last_seen" TIMESTAMP(3),
ADD COLUMN "agent_url" TEXT,
ADD COLUMN "agent_version" TEXT,
ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ccp_certificate_authority" (
"id" TEXT NOT NULL,
"common_name" TEXT NOT NULL,
"encrypted_key" TEXT NOT NULL,
"cert_pem" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ccp_certificate_authority_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "issued_agent_certs" (
"id" TEXT NOT NULL,
"ca_id" TEXT NOT NULL,
"instance_id" TEXT NOT NULL,
"common_name" TEXT NOT NULL,
"encrypted_key" TEXT NOT NULL,
"cert_pem" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
CONSTRAINT "issued_agent_certs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_invite_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"created_by_id" TEXT NOT NULL,
"used_by_id" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agent_invite_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_registrations" (
"id" TEXT NOT NULL,
"invite_code_id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"agent_url" TEXT NOT NULL,
"base_path" TEXT NOT NULL,
"compose_project" TEXT NOT NULL,
"metadata" JSONB,
"status" "AgentRegistrationStatus" NOT NULL DEFAULT 'PENDING',
"instance_id" TEXT,
"approved_by_id" TEXT,
"approved_at" TIMESTAMP(3),
"rejected_at" TIMESTAMP(3),
"cert_bundle" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agent_registrations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "issued_agent_certs_instance_id_key" ON "issued_agent_certs"("instance_id");
-- CreateIndex
CREATE INDEX "issued_agent_certs_instance_id_idx" ON "issued_agent_certs"("instance_id");
-- CreateIndex
CREATE UNIQUE INDEX "agent_invite_codes_code_key" ON "agent_invite_codes"("code");
-- CreateIndex
CREATE INDEX "agent_registrations_status_idx" ON "agent_registrations"("status");
-- AddForeignKey
ALTER TABLE "issued_agent_certs" ADD CONSTRAINT "issued_agent_certs_ca_id_fkey" FOREIGN KEY ("ca_id") REFERENCES "ccp_certificate_authority"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issued_agent_certs" ADD CONSTRAINT "issued_agent_certs_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_invite_codes" ADD CONSTRAINT "agent_invite_codes_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "ccp_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -28,6 +28,7 @@ model CcpUser {
auditLogs AuditLog[]
triggeredUpgrades InstanceUpgrade[]
acknowledgedEvents InstanceEvent[]
agentInviteCodes AgentInviteCode[]
@@map("ccp_users")
}
@ -78,6 +79,13 @@ model Instance {
// True if this instance was registered externally (not provisioned by CCP)
isRegistered Boolean @default(false) @map("is_registered")
// Remote agent management
isRemote Boolean @default(false) @map("is_remote")
agentUrl String? @map("agent_url")
agentFingerprint String? @map("agent_fingerprint")
agentVersion String? @map("agent_version")
agentLastSeen DateTime? @map("agent_last_seen")
// Feature flags
enableMedia Boolean @default(false) @map("enable_media")
enableChat Boolean @default(false) @map("enable_chat")
@ -120,6 +128,7 @@ model Instance {
auditLogs AuditLog[]
upgrades InstanceUpgrade[]
events InstanceEvent[]
agentCert IssuedAgentCert?
@@map("instances")
}
@ -208,6 +217,14 @@ enum AuditAction {
BACKUP_DELETE
PANGOLIN_SETUP
PANGOLIN_SYNC
AGENT_CONNECT
AGENT_REGISTER
AGENT_APPROVE
AGENT_REJECT
INVITE_CREATE
INVITE_REVOKE
CERT_ISSUE
CERT_REVOKE
USER_LOGIN
USER_CREATE
USER_UPDATE
@ -313,3 +330,81 @@ model CcpSetting {
@@map("ccp_settings")
}
// ─── Remote Agent Management ──────────────────────────────
model CcpCertificateAuthority {
id String @id @default(uuid())
commonName String @map("common_name")
encryptedKey String @map("encrypted_key")
certPem String @map("cert_pem")
fingerprint String
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
issuedCerts IssuedAgentCert[]
@@map("ccp_certificate_authority")
}
model IssuedAgentCert {
id String @id @default(uuid())
caId String @map("ca_id")
instanceId String @unique @map("instance_id")
commonName String @map("common_name")
encryptedKey String @map("encrypted_key")
certPem String @map("cert_pem")
fingerprint String
issuedAt DateTime @default(now()) @map("issued_at")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
ca CcpCertificateAuthority @relation(fields: [caId], references: [id])
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
@@index([instanceId])
@@map("issued_agent_certs")
}
model AgentInviteCode {
id String @id @default(uuid())
code String @unique
createdById String @map("created_by_id")
usedById String? @map("used_by_id")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
createdBy CcpUser @relation(fields: [createdById], references: [id])
@@map("agent_invite_codes")
}
enum AgentRegistrationStatus {
PENDING
APPROVED
REJECTED
EXPIRED
}
model AgentRegistration {
id String @id @default(uuid())
inviteCodeId String @map("invite_code_id")
slug String
name String
domain String
agentUrl String @map("agent_url")
basePath String @map("base_path")
composeProject String @map("compose_project")
metadata Json?
status AgentRegistrationStatus @default(PENDING)
instanceId String? @map("instance_id")
approvedById String? @map("approved_by_id")
approvedAt DateTime? @map("approved_at")
rejectedAt DateTime? @map("rejected_at")
certBundle Json? @map("cert_bundle")
createdAt DateTime @default(now()) @map("created_at")
@@index([status])
@@map("agent_registrations")
}

View File

@ -62,6 +62,12 @@ const envSchema = z.object({
// Health checks
HEALTH_CHECK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 min (0 to disable)
// Remote agent defaults
AGENT_CONNECT_TIMEOUT_MS: z.coerce.number().default(10_000),
AGENT_REQUEST_TIMEOUT_MS: z.coerce.number().default(30_000),
AGENT_LONG_OP_TIMEOUT_MS: z.coerce.number().default(600_000), // 10 min for backups/builds
AGENT_HEALTH_FAILURE_THRESHOLD: z.coerce.number().default(3),
// Backups
BACKUP_STORAGE_PATH: z.string().default(
path.resolve(process.cwd(), '..', 'backups')

View File

@ -0,0 +1,248 @@
import { Router, Request, Response } from 'express';
import rateLimit from 'express-rate-limit';
import { prisma } from '../../lib/prisma';
import { Prisma, AuditAction, InstanceStatus, AgentRegistrationStatus } from '@prisma/client';
import { validateInviteCode, markCodeUsed } from '../../services/invite-code.service';
import { issueAgentCert } from '../../services/certificate.service';
import { authenticate, requireRole } from '../../middleware/auth';
import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
const router = Router();
// SECURITY: Strict rate limiter for unauthenticated agent endpoints
const agentRegistrationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'RATE_LIMITED', message: 'Too many registration attempts, try again later' },
});
// ─── Public Endpoints (used by remote agents during phone-home) ──────
/**
* POST /api/agents/register
* Agent phones home with invite code + instance metadata.
* Creates a PENDING registration for admin approval.
*/
router.post('/register', agentRegistrationLimiter, async (req: Request, res: Response) => {
const { inviteCode, slug, name, domain, agentUrl, basePath, composeProject, metadata } = req.body;
if (!inviteCode || !slug || !agentUrl) {
throw new AppError(400, 'inviteCode, slug, and agentUrl are required');
}
// Validate invite code
const invite = await validateInviteCode(inviteCode);
// Check for duplicate pending registrations
const existing = await prisma.agentRegistration.findFirst({
where: { slug, status: AgentRegistrationStatus.PENDING },
});
if (existing) {
res.json({ registrationId: existing.id, status: 'PENDING' });
return;
}
// Create pending registration
const registration = await prisma.agentRegistration.create({
data: {
inviteCodeId: invite.id,
slug: slug || '',
name: name || slug || '',
domain: domain || '',
agentUrl,
basePath: basePath || '',
composeProject: composeProject || slug || '',
metadata: metadata || null,
},
});
logger.info(`[agents] New registration request: ${slug} from ${agentUrl} (invite: ${invite.code})`);
res.status(201).json({
registrationId: registration.id,
status: 'PENDING',
message: 'Registration submitted — waiting for admin approval',
});
});
/**
* GET /api/agents/poll
* Agent polls to check if registration was approved.
* Returns cert bundle on approval.
*/
router.get('/poll', agentRegistrationLimiter, async (req: Request, res: Response) => {
const { registrationId, slug } = req.query;
if (!registrationId && !slug) {
throw new AppError(400, 'registrationId or slug required');
}
const registration = await prisma.agentRegistration.findFirst({
where: registrationId
? { id: registrationId as string }
: { slug: slug as string, status: { in: [AgentRegistrationStatus.PENDING, AgentRegistrationStatus.APPROVED] } },
orderBy: { createdAt: 'desc' },
});
if (!registration) {
throw new AppError(404, 'Registration not found');
}
if (registration.status === AgentRegistrationStatus.APPROVED && registration.certBundle) {
// Return cert bundle — agent will save certs and restart with mTLS
const bundle = registration.certBundle;
// SECURITY: Wipe the cert bundle (contains private key) after first delivery.
// The agent gets one chance to retrieve it; after that it's gone from the DB.
await prisma.agentRegistration.update({
where: { id: registration.id },
data: { certBundle: Prisma.DbNull },
});
logger.info(`[agents] Cert bundle delivered and wiped for ${registration.slug}`);
res.json({
status: 'APPROVED',
certBundle: bundle,
});
return;
}
if (registration.status === AgentRegistrationStatus.APPROVED && !registration.certBundle) {
// Cert bundle was already delivered and wiped — agent must re-issue if it missed it
res.json({ status: 'APPROVED', certBundle: null, message: 'Certificate bundle already delivered. Contact admin to re-issue.' });
return;
}
if (registration.status === AgentRegistrationStatus.REJECTED) {
res.json({ status: 'REJECTED' });
return;
}
res.json({ status: 'PENDING' });
});
// ─── Authenticated Endpoints (CCP admin) ─────────────────────────────
/**
* GET /api/agents/registrations
* List all agent registrations (pending, approved, rejected).
*/
router.get('/registrations', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
const registrations = await prisma.agentRegistration.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
});
res.json(registrations);
});
/**
* POST /api/agents/registrations/:id/approve
* Approve a pending registration: issue certs, create Instance, mark approved.
*/
router.post('/registrations/:id/approve', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
const { id } = req.params;
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
if (!registration) throw new AppError(404, 'Registration not found');
if (registration.status !== AgentRegistrationStatus.PENDING) {
throw new AppError(400, `Registration is ${registration.status}, not PENDING`);
}
// Create the Instance record
const instance = await prisma.instance.create({
data: {
slug: registration.slug,
name: registration.name,
domain: registration.domain,
status: InstanceStatus.STOPPED,
statusMessage: 'Remote instance registered — agent connecting',
basePath: registration.basePath,
composeProject: registration.composeProject,
portConfig: (registration.metadata as Record<string, unknown>)?.portConfig || { api: 4000, admin: 3000, postgres: 5432, nginx: 80 },
isRegistered: true,
isRemote: true,
agentUrl: registration.agentUrl,
adminEmail: (registration.metadata as Record<string, unknown>)?.adminEmail as string || 'admin@example.com',
},
});
// Issue mTLS certificates
const certMaterials = await issueAgentCert(instance.id, registration.slug);
// Mark invite code as used
const invite = await prisma.agentInviteCode.findUnique({ where: { id: registration.inviteCodeId } });
if (invite && !invite.usedAt) {
await markCodeUsed(invite.code, instance.id);
}
// Update registration with approval + cert bundle
await prisma.agentRegistration.update({
where: { id: id as string },
data: {
status: AgentRegistrationStatus.APPROVED,
instanceId: instance.id,
approvedById: (req as unknown as { user: { id: string } }).user.id,
approvedAt: new Date(),
certBundle: {
caCertPem: certMaterials.caCertPem,
agentCertPem: certMaterials.agentCertPem,
agentKeyPem: certMaterials.agentKeyPem,
ccpFingerprint: certMaterials.caFingerprint,
},
},
});
// Audit log
await prisma.auditLog.create({
data: {
userId: (req as unknown as { user: { id: string } }).user.id,
instanceId: instance.id,
action: AuditAction.AGENT_APPROVE,
details: { slug: registration.slug, agentUrl: registration.agentUrl },
ipAddress: req.ip || null,
},
});
logger.info(`[agents] Registration approved: ${registration.slug} → instance ${instance.id}`);
res.json({
message: 'Registration approved — agent will receive certificates on next poll',
instanceId: instance.id,
});
});
/**
* POST /api/agents/registrations/:id/reject
* Reject a pending registration.
*/
router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const { id } = req.params;
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
if (!registration) throw new AppError(404, 'Registration not found');
if (registration.status !== AgentRegistrationStatus.PENDING) {
throw new AppError(400, `Registration is ${registration.status}, not PENDING`);
}
await prisma.agentRegistration.update({
where: { id: id as string },
data: {
status: AgentRegistrationStatus.REJECTED,
rejectedAt: new Date(),
},
});
await prisma.auditLog.create({
data: {
userId: (req as unknown as { user: { id: string } }).user.id,
action: AuditAction.AGENT_REJECT,
details: { slug: registration.slug, agentUrl: registration.agentUrl },
ipAddress: req.ip || null,
},
});
res.json({ message: 'Registration rejected' });
});
export default router;

View File

@ -0,0 +1,16 @@
import { Router, Request, Response } from 'express';
import { authenticate, requireRole } from '../../middleware/auth';
import { getCACert } from '../../services/certificate.service';
const router = Router();
/**
* GET /api/certificates/ca
* Get the CCP CA public certificate (for manual agent setup).
*/
router.get('/ca', authenticate, requireRole('SUPER_ADMIN'), async (_req: Request, res: Response) => {
const ca = await getCACert();
res.json(ca);
});
export default router;

View File

@ -16,6 +16,7 @@ export const createInstanceSchema = z.object({
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
enableAnalytics: z.boolean().default(false),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
@ -42,6 +43,7 @@ export const updateInstanceSchema = z.object({
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).nullable().optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
@ -76,6 +78,7 @@ export const registerInstanceSchema = z.object({
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
enableAnalytics: z.boolean().default(false),
emailTestMode: z.boolean().default(true),
notes: z.string().optional(),
});
@ -92,6 +95,7 @@ export const reconfigureInstanceSchema = z.object({
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
});
export const configureTunnelSchema = z.object({

View File

@ -8,6 +8,7 @@ import { encryptJson, decryptJson } from '../../utils/encryption';
import { generateSecrets } from '../../services/secret-generator';
import { allocatePorts, releasePorts } from '../../services/port-allocator';
import * as docker from '../../services/docker.service';
import { getDriverForInstance, AgentUnreachableError } from '../../services/execution-driver';
import { provision } from './provisioner';
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput, ConfigureTunnelInput } from './instances.schemas';
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
@ -86,6 +87,7 @@ export async function createInstance(input: CreateInstanceInput, userId: string,
enableSms: input.enableSms,
enableSocial: input.enableSocial,
enablePeople: input.enablePeople,
enableAnalytics: input.enableAnalytics,
jvbAdvertiseIp: input.jvbAdvertiseIp,
adminEmail: input.adminEmail,
pangolinEndpoint: input.enablePangolin ? input.pangolinEndpoint : null,
@ -184,6 +186,7 @@ export async function registerInstance(input: RegisterInstanceInput, userId: str
enableSms: input.enableSms,
enableSocial: input.enableSocial,
enablePeople: input.enablePeople,
enableAnalytics: input.enableAnalytics,
adminEmail: input.adminEmail,
notes: input.notes,
},
@ -282,7 +285,8 @@ export async function deleteInstance(id: string, userId: string, ipAddress?: str
// Stop containers and remove volumes
try {
await docker.composeDown(instance.basePath, instance.composeProject, true);
const driver = await getDriverForInstance(instance);
await driver.composeDown(instance.basePath, instance.composeProject, true);
logger.info(`[instances] ${instance.slug}: Containers stopped and volumes removed`);
} catch (err) {
logger.warn(`[instances] ${instance.slug}: Docker cleanup warning: ${(err as Error).message}`);
@ -413,7 +417,8 @@ export async function startInstance(id: string, userId: string, ipAddress?: stri
}
try {
await docker.composeUp(instance.basePath, instance.composeProject);
const driver = await getDriverForInstance(instance);
await driver.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
@ -432,6 +437,13 @@ export async function startInstance(id: string, userId: string, ipAddress?: stri
return { message: 'Instance started' };
} catch (err) {
if (err instanceof AgentUnreachableError) {
await prisma.instance.update({
where: { id },
data: { status: InstanceStatus.ERROR, statusMessage: `Agent unreachable: ${err.agentUrl}` },
});
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
@ -452,7 +464,8 @@ export async function stopInstance(id: string, userId: string, ipAddress?: strin
}
try {
await docker.composeStop(instance.basePath, instance.composeProject);
const driver = await getDriverForInstance(instance);
await driver.composeStop(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
@ -471,6 +484,9 @@ export async function stopInstance(id: string, userId: string, ipAddress?: strin
return { message: 'Instance stopped' };
} catch (err) {
if (err instanceof AgentUnreachableError) {
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to stop instance: ${errorMsg}`, 'DOCKER_ERROR');
}
@ -483,7 +499,8 @@ export async function restartInstance(id: string, userId: string, ipAddress?: st
}
try {
await docker.composeRestart(instance.basePath, instance.composeProject, service);
const driver = await getDriverForInstance(instance);
await driver.composeRestart(instance.basePath, instance.composeProject, service);
await prisma.auditLog.create({
data: {
@ -497,6 +514,9 @@ export async function restartInstance(id: string, userId: string, ipAddress?: st
return { message: `${service || 'All services'} restarted` };
} catch (err) {
if (err instanceof AgentUnreachableError) {
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to restart: ${errorMsg}`, 'DOCKER_ERROR');
}
@ -509,9 +529,10 @@ export async function getInstanceServices(id: string) {
}
try {
return await docker.composePs(instance.basePath, instance.composeProject);
const driver = await getDriverForInstance(instance);
return await driver.composePs(instance.basePath, instance.composeProject);
} catch {
// If compose ps fails (e.g. no containers), return empty array
// If compose ps fails (e.g. no containers or agent unreachable), return empty array
return [];
}
}
@ -528,7 +549,8 @@ export async function getInstanceLogs(
}
try {
return await docker.composeLogs(
const driver = await getDriverForInstance(instance);
return await driver.composeLogs(
instance.basePath,
instance.composeProject,
service,
@ -536,6 +558,9 @@ export async function getInstanceLogs(
since
);
} catch (err) {
if (err instanceof AgentUnreachableError) {
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
throw new AppError(500, `Failed to get logs: ${(err as Error).message}`, 'DOCKER_ERROR');
}
}
@ -577,12 +602,21 @@ export async function reconfigureInstance(
// Re-render templates with updated flags
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
await renderAllTemplates(context, instance.basePath);
const driver = await getDriverForInstance(instance);
if (instance.isRemote) {
// Remote: render in memory, send files to agent
const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
const files = await renderAllTemplatesInMemory(context);
await driver.writeFiles(instance.basePath, files);
} else {
await renderAllTemplates(context, instance.basePath);
}
// If instance is running, apply changes via docker compose up
if (instance.status === 'RUNNING') {
try {
await docker.composeUp(instance.basePath, instance.composeProject);
await driver.composeUp(instance.basePath, instance.composeProject);
// --remove-orphans (from composeUp) will clean up disabled services
await prisma.instance.update({
@ -590,6 +624,13 @@ export async function reconfigureInstance(
data: { statusMessage: 'Reconfiguration complete' },
});
} catch (err) {
if (err instanceof AgentUnreachableError) {
await prisma.instance.update({
where: { id },
data: { statusMessage: `Agent unreachable: ${(err as AgentUnreachableError).agentUrl}` },
});
throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
}
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
@ -661,12 +702,20 @@ export async function configureTunnel(
clearTemplateCache();
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
await renderAllTemplates(context, instance.basePath);
const driver = await getDriverForInstance(instance);
if (instance.isRemote) {
const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
const files = await renderAllTemplatesInMemory(context);
await driver.writeFiles(instance.basePath, files);
} else {
await renderAllTemplates(context, instance.basePath);
}
// If running, bring up the newt container
if (instance.status === 'RUNNING') {
try {
await docker.composeUp(instance.basePath, instance.composeProject, ['newt']);
await driver.composeUp(instance.basePath, instance.composeProject, ['newt']);
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Tunnel configured and Newt started' },
@ -738,12 +787,20 @@ export async function removeTunnel(
clearTemplateCache();
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
await renderAllTemplates(context, instance.basePath);
const driver = await getDriverForInstance(instance);
if (instance.isRemote) {
const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
const files = await renderAllTemplatesInMemory(context);
await driver.writeFiles(instance.basePath, files);
} else {
await renderAllTemplates(context, instance.basePath);
}
// If running, full compose up with --remove-orphans removes the orphaned newt container
if (instance.status === 'RUNNING') {
try {
await docker.composeUp(instance.basePath, instance.composeProject);
await driver.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Tunnel removed' },

View File

@ -0,0 +1,62 @@
import { Router, Request, Response } from 'express';
import { authenticate, requireRole } from '../../middleware/auth';
import { AuditAction } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { createInviteCode, listInviteCodes, revokeInviteCode } from '../../services/invite-code.service';
const router = Router();
/**
* POST /api/invite-codes
* Generate a new invite code for agent registration.
*/
router.post('/', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const userId = (req as unknown as { user: { id: string } }).user.id;
const { expiryHours } = req.body || {};
const invite = await createInviteCode(userId, expiryHours);
await prisma.auditLog.create({
data: {
userId,
action: AuditAction.INVITE_CREATE,
details: { code: invite.code, expiresAt: invite.expiresAt.toISOString() },
ipAddress: req.ip || null,
},
});
res.status(201).json(invite);
});
/**
* GET /api/invite-codes
* List all invite codes.
*/
router.get('/', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 50;
const result = await listInviteCodes(page, limit);
res.json(result);
});
/**
* DELETE /api/invite-codes/:id
* Revoke an unused invite code.
*/
router.delete('/:id', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const userId = (req as unknown as { user: { id: string } }).user.id;
await revokeInviteCode(req.params.id as string);
await prisma.auditLog.create({
data: {
userId,
action: AuditAction.INVITE_REVOKE,
details: { inviteCodeId: req.params.id },
ipAddress: req.ip || null,
},
});
res.json({ message: 'Invite code revoked' });
});
export default router;

View File

@ -16,6 +16,9 @@ import healthRoutes from './modules/health/health.routes';
import auditRoutes from './modules/audit/audit.routes';
import backupRoutes from './modules/backups/backup.routes';
import eventsRoutes, { instanceEventsRouter } from './modules/events/events.routes';
import agentRoutes from './modules/agents/agents.routes';
import certificateRoutes from './modules/certificates/certificates.routes';
import inviteCodeRoutes from './modules/invite-codes/invite-codes.routes';
import { startHealthScheduler } from './services/health.service';
import { autoDiscoverOnStartup } from './services/discovery.service';
@ -60,6 +63,9 @@ app.use('/api/audit', auditRoutes);
app.use('/api/backups', backupRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/instances/:id/events', instanceEventsRouter);
app.use('/api/agents', agentRoutes);
app.use('/api/certificates', certificateRoutes);
app.use('/api/invite-codes', inviteCodeRoutes);
// Error handler (must be last)
app.use(errorHandler);

View File

@ -0,0 +1,236 @@
import crypto from 'crypto';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { prisma } from '../lib/prisma';
import { encrypt, decrypt } from '../utils/encryption';
import { logger } from '../utils/logger';
const exec = promisify(execCb);
const CA_VALIDITY_DAYS = 3650; // ~10 years
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
function computeFingerprint(certPem: string): string {
const der = Buffer.from(
certPem
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s/g, ''),
'base64'
);
return crypto.createHash('sha256').update(der).digest('hex');
}
/**
* Run openssl commands in a temp directory, then clean up.
*/
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ccp-cert-'));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
/**
* Ensure a Certificate Authority exists. Creates one if none exists.
*/
export async function ensureCA() {
const existing = await prisma.ccpCertificateAuthority.findFirst({
orderBy: { createdAt: 'desc' },
});
if (existing && existing.expiresAt > new Date()) {
return existing;
}
logger.info('Generating new CCP Certificate Authority...');
const { keyPem, certPem } = await withTempDir(async (dir) => {
const keyFile = path.join(dir, 'ca.key');
const certFile = path.join(dir, 'ca.crt');
// Generate CA key + self-signed cert
await exec(
`openssl req -x509 -newkey rsa:4096 -keyout "${keyFile}" -out "${certFile}" ` +
`-days ${CA_VALIDITY_DAYS} -nodes ` +
`-subj "/CN=CCP Certificate Authority/O=Changemaker Control Panel"`,
{ timeout: 30_000 }
);
return {
keyPem: await fs.readFile(keyFile, 'utf-8'),
certPem: await fs.readFile(certFile, 'utf-8'),
};
});
const fingerprint = computeFingerprint(certPem);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + CA_VALIDITY_DAYS);
const ca = await prisma.ccpCertificateAuthority.create({
data: {
commonName: 'CCP Certificate Authority',
encryptedKey: encrypt(keyPem),
certPem,
fingerprint,
expiresAt,
},
});
logger.info(`CA created: fingerprint=${fingerprint.substring(0, 16)}...`);
return ca;
}
/**
* Issue a certificate for a remote agent, signed by the CA.
* Returns the certificate materials (plaintext) for one-time display.
*/
export async function issueAgentCert(instanceId: string, slug: string) {
const ca = await ensureCA();
const caKeyPem = decrypt(ca.encryptedKey);
const commonName = `ccp-agent-${slug}`;
const { agentKeyPem, agentCertPem } = await withTempDir(async (dir) => {
const caKeyFile = path.join(dir, 'ca.key');
const caCertFile = path.join(dir, 'ca.crt');
const agentKeyFile = path.join(dir, 'agent.key');
const agentCsrFile = path.join(dir, 'agent.csr');
const agentCertFile = path.join(dir, 'agent.crt');
const serialFile = path.join(dir, 'serial');
const extFile = path.join(dir, 'ext.cnf');
// Write CA materials
await fs.writeFile(caKeyFile, caKeyPem);
await fs.writeFile(caCertFile, ca.certPem);
await fs.writeFile(serialFile, crypto.randomBytes(16).toString('hex'));
// Extensions for server+client auth
await fs.writeFile(extFile, [
'basicConstraints=CA:FALSE',
'keyUsage=digitalSignature,keyEncipherment',
'extendedKeyUsage=serverAuth,clientAuth',
].join('\n'));
// Generate agent key
await exec(
`openssl genrsa -out "${agentKeyFile}" 2048`,
{ timeout: 15_000 }
);
// Generate CSR
await exec(
`openssl req -new -key "${agentKeyFile}" -out "${agentCsrFile}" ` +
`-subj "/CN=${commonName}/O=Changemaker Lite Agent"`,
{ timeout: 15_000 }
);
// Sign CSR with CA
await exec(
`openssl x509 -req -in "${agentCsrFile}" ` +
`-CA "${caCertFile}" -CAkey "${caKeyFile}" ` +
`-CAserial "${serialFile}" ` +
`-out "${agentCertFile}" -days ${AGENT_CERT_VALIDITY_DAYS} ` +
`-extfile "${extFile}"`,
{ timeout: 15_000 }
);
return {
agentKeyPem: await fs.readFile(agentKeyFile, 'utf-8'),
agentCertPem: await fs.readFile(agentCertFile, 'utf-8'),
};
});
const fingerprint = computeFingerprint(agentCertPem);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + AGENT_CERT_VALIDITY_DAYS);
// Revoke any existing cert for this instance
await prisma.issuedAgentCert.deleteMany({ where: { instanceId } });
// Store the issued cert
await prisma.issuedAgentCert.create({
data: {
caId: ca.id,
instanceId,
commonName,
encryptedKey: encrypt(agentKeyPem),
certPem: agentCertPem,
fingerprint,
expiresAt,
},
});
// Update instance with the agent fingerprint
await prisma.instance.update({
where: { id: instanceId },
data: { agentFingerprint: fingerprint },
});
logger.info(`Agent cert issued for ${slug}: fingerprint=${fingerprint.substring(0, 16)}...`);
return {
caCertPem: ca.certPem,
agentCertPem,
agentKeyPem, // Plaintext — display once, never retrievable again
fingerprint,
caFingerprint: ca.fingerprint,
};
}
/**
* Revoke an agent's certificate.
*/
export async function revokeAgentCert(instanceId: string) {
const cert = await prisma.issuedAgentCert.findUnique({ where: { instanceId } });
if (!cert) return;
await prisma.issuedAgentCert.update({
where: { id: cert.id },
data: { revokedAt: new Date() },
});
await prisma.instance.update({
where: { id: instanceId },
data: { agentFingerprint: null },
});
logger.info(`Agent cert revoked for instance ${instanceId}`);
}
/**
* Get the mTLS materials CCP needs to present when calling a remote agent.
*/
export async function getAgentClientMaterials(instanceId: string) {
const cert = await prisma.issuedAgentCert.findUnique({
where: { instanceId },
include: { ca: true },
});
if (!cert || cert.revokedAt) return null;
return {
agentCertPem: cert.certPem,
agentKeyPem: decrypt(cert.encryptedKey),
caCertPem: cert.ca.certPem,
fingerprint: cert.fingerprint,
expiresAt: cert.expiresAt,
};
}
/**
* Get the CA public certificate (for manual agent setup).
*/
export async function getCACert() {
const ca = await ensureCA();
return {
certPem: ca.certPem,
fingerprint: ca.fingerprint,
expiresAt: ca.expiresAt,
};
}

View File

@ -32,6 +32,7 @@ export interface DiscoveredInstance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
emailTestMode: boolean;
// Discovery metadata (UI-only, not persisted)
source: 'parent' | 'docker';
@ -388,6 +389,7 @@ export async function autoDiscoverOnStartup(): Promise<void> {
enableSms: inst.enableSms,
enableSocial: inst.enableSocial,
enablePeople: inst.enablePeople,
enableAnalytics: inst.enableAnalytics,
emailTestMode: inst.emailTestMode,
},
userId,

View File

@ -0,0 +1,82 @@
import type { ContainerInfo } from './docker.service';
/**
* Abstraction layer for instance operations.
* LocalDriver wraps docker.service.ts + filesystem.
* RemoteDriver makes HTTPS calls to the remote agent.
*/
export interface ExecutionDriver {
// ─── Docker Compose Operations ──────────────────────────────
composeUp(projectDir: string, project: string, services?: string[]): Promise<string>;
composeDown(projectDir: string, project: string, removeVolumes?: boolean): Promise<string>;
composeStop(projectDir: string, project: string): Promise<string>;
composeRestart(projectDir: string, project: string, service?: string): Promise<string>;
composePull(projectDir: string, project: string): Promise<string>;
composeBuild(projectDir: string, project: string): Promise<string>;
composePs(projectDir: string, project: string): Promise<ContainerInfo[]>;
composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string): Promise<string>;
composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>): Promise<string>;
// ─── Container Health ───────────────────────────────────────
waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number): Promise<boolean>;
waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number): Promise<boolean>;
// ─── Filesystem Operations ──────────────────────────────────
readEnvFile(basePath: string): Promise<Record<string, string> | null>;
writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void>;
mkdir(basePath: string, relativePath: string): Promise<void>;
fileExists(basePath: string, relativePath: string): Promise<boolean>;
deleteDirectory(dirPath: string): Promise<void>;
cloneSource(basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise<void>;
}
/**
* Error thrown when a remote agent is unreachable.
*/
export class AgentUnreachableError extends Error {
constructor(public agentUrl: string, cause?: Error) {
super(`Remote agent at ${agentUrl} is not reachable`);
this.name = 'AgentUnreachableError';
if (cause) this.cause = cause;
}
}
/**
* Minimal instance shape needed to resolve a driver.
*/
export interface DriverInstance {
id: string;
slug: string;
isRemote: boolean;
agentUrl: string | null;
}
/**
* Resolve the correct execution driver for an instance.
* Returns LocalDriver for local instances, RemoteDriver for remote ones.
*/
export async function getDriverForInstance(instance: DriverInstance): Promise<ExecutionDriver> {
if (!instance.isRemote) {
const { getLocalDriver } = await import('./local-driver');
return getLocalDriver();
}
if (!instance.agentUrl) {
throw new Error(`Remote instance ${instance.slug} has no agent URL configured`);
}
const { getAgentClientMaterials } = await import('./certificate.service');
const materials = await getAgentClientMaterials(instance.id);
if (!materials) {
throw new Error(`No valid certificate found for remote instance ${instance.slug}`);
}
const { RemoteDriver } = await import('./remote-driver');
return new RemoteDriver(
instance.agentUrl,
instance.slug,
Buffer.from(materials.agentCertPem),
Buffer.from(materials.agentKeyPem),
Buffer.from(materials.caCertPem)
);
}

View File

@ -1,6 +1,10 @@
import fs from 'fs/promises';
import path from 'path';
import { parse as parseDotenv } from 'dotenv';
import { InstanceStatus, HealthStatus } from '@prisma/client';
import { prisma } from '../lib/prisma';
import * as docker from './docker.service';
import { getDriverForInstance, AgentUnreachableError } from './execution-driver';
import { logger } from '../utils/logger';
import { createEvent } from './event.service';
import type { ContainerInfo } from './docker.service';
@ -52,8 +56,43 @@ function determineHealth(containers: ContainerInfo[]): {
return { status, serviceStatus, totalServices: total, healthyServices: healthyCount };
}
/**
* Parse an instance's .env file and return all variables.
* Returns null if the file doesn't exist or can't be read.
*/
async function readEnvFile(basePath: string): Promise<Record<string, string> | null> {
try {
const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
return parseDotenv(Buffer.from(content));
} catch {
return null;
}
}
/**
* Extract feature flags from parsed .env variables.
*/
function extractFeatureFlags(envVars: Record<string, string>): Record<string, boolean> {
const isTrue = (val?: string) => val?.toLowerCase() === 'true';
return {
enableMedia: isTrue(envVars.ENABLE_MEDIA_FEATURES),
enableChat: isTrue(envVars.ENABLE_CHAT),
enableGancio: isTrue(envVars.GANCIO_SYNC_ENABLED),
enableListmonk: isTrue(envVars.LISTMONK_SYNC_ENABLED),
enablePayments: isTrue(envVars.ENABLE_PAYMENTS),
enableMeet: isTrue(envVars.ENABLE_MEET),
enableSms: isTrue(envVars.ENABLE_SMS),
enableSocial: isTrue(envVars.ENABLE_SOCIAL),
enablePeople: isTrue(envVars.ENABLE_PEOPLE),
enableAnalytics: isTrue(envVars.ENABLE_ANALYTICS),
};
}
/**
* Check the health of a single instance. Returns the created HealthCheck record.
* Also auto-corrects instance.status based on actual container state:
* - RUNNING instance with 0 containers STOPPED
* - STOPPED instance with running containers RUNNING
*/
export async function checkInstanceHealth(instanceId: string) {
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
@ -61,17 +100,29 @@ export async function checkInstanceHealth(instanceId: string) {
throw new Error(`Instance ${instanceId} not found`);
}
if (instance.status !== InstanceStatus.RUNNING) {
throw new Error(`Instance ${instance.slug} is not running (status: ${instance.status})`);
// Only check RUNNING or STOPPED instances (skip PROVISIONING, ERROR, DESTROYING)
if (instance.status !== InstanceStatus.RUNNING && instance.status !== InstanceStatus.STOPPED) {
throw new Error(`Instance ${instance.slug} is not checkable (status: ${instance.status})`);
}
const startTime = Date.now();
let containers: ContainerInfo[];
const driver = await getDriverForInstance(instance);
try {
containers = await docker.composePs(instance.basePath, instance.composeProject);
containers = await driver.composePs(instance.basePath, instance.composeProject);
} catch (err) {
// If compose ps fails, record UNKNOWN status
const updateData: { lastHealthCheck: Date; status?: InstanceStatus } = {
lastHealthCheck: new Date(),
};
// If we thought it was RUNNING but can't even reach compose, mark as STOPPED
if (instance.status === InstanceStatus.RUNNING) {
updateData.status = InstanceStatus.STOPPED;
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (compose ps failed)`);
}
const healthCheck = await prisma.healthCheck.create({
data: {
instanceId,
@ -85,7 +136,7 @@ export async function checkInstanceHealth(instanceId: string) {
await prisma.instance.update({
where: { id: instanceId },
data: { lastHealthCheck: new Date() },
data: updateData,
});
logger.warn(`[health] ${instance.slug}: compose ps failed: ${(err as Error).message}`);
@ -95,6 +146,62 @@ export async function checkInstanceHealth(instanceId: string) {
const responseTimeMs = Date.now() - startTime;
const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers);
// Auto-correct instance status based on actual container state
const hasRunningContainers = containers.some((c) => c.state === 'running');
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.STOPPED },
});
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.RUNNING },
});
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
}
// Sync domain and feature flags from .env if they have drifted
const envVars = instance.isRemote
? await driver.readEnvFile(instance.basePath)
: await readEnvFile(instance.basePath);
if (envVars) {
const driftUpdates: Record<string, unknown> = {};
// Domain sync
const envDomain = envVars.DOMAIN;
if (envDomain && envDomain !== instance.domain) {
driftUpdates.domain = envDomain;
logger.info(`[health] ${instance.slug}: synced domain ${instance.domain}${envDomain}`);
}
// Feature flag sync (only for registered/external instances)
if (instance.isRegistered) {
const envFlags = extractFeatureFlags(envVars);
const flagKeys = Object.keys(envFlags) as Array<keyof typeof envFlags>;
for (const key of flagKeys) {
if ((instance as Record<string, unknown>)[key] !== envFlags[key]) {
driftUpdates[key] = envFlags[key];
}
}
if (Object.keys(driftUpdates).length > (envDomain && envDomain !== instance.domain ? 1 : 0)) {
const changedFlags = flagKeys.filter(k => (instance as Record<string, unknown>)[k] !== envFlags[k]);
if (changedFlags.length > 0) {
logger.info(`[health] ${instance.slug}: synced feature flags: ${changedFlags.join(', ')}`);
}
}
}
if (Object.keys(driftUpdates).length > 0) {
await prisma.instance.update({
where: { id: instanceId },
data: driftUpdates,
});
}
}
// Get the previous health check to detect transitions
const previousCheck = await prisma.healthCheck.findFirst({
where: { instanceId },
@ -113,9 +220,13 @@ export async function checkInstanceHealth(instanceId: string) {
},
});
const healthUpdateData: Record<string, unknown> = { lastHealthCheck: new Date() };
if (instance.isRemote) {
healthUpdateData.agentLastSeen = new Date();
}
await prisma.instance.update({
where: { id: instanceId },
data: { lastHealthCheck: new Date() },
data: healthUpdateData,
});
// Create events on health transitions
@ -160,16 +271,17 @@ export async function checkInstanceHealth(instanceId: string) {
}
/**
* Check all running instances sequentially.
* Check all checkable instances (RUNNING + STOPPED) sequentially.
* STOPPED instances are checked so we can detect when they come back online.
*/
export async function checkAllInstances(): Promise<void> {
const instances = await prisma.instance.findMany({
where: { status: InstanceStatus.RUNNING },
select: { id: true, slug: true },
where: { status: { in: [InstanceStatus.RUNNING, InstanceStatus.STOPPED] } },
select: { id: true, slug: true, status: true },
});
if (instances.length === 0) {
logger.debug('[health] No running instances to check');
logger.debug('[health] No checkable instances');
return;
}

View File

@ -0,0 +1,122 @@
import crypto from 'crypto';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/error-handler';
const CODE_LENGTH = 8; // e.g., "A3X7-K9M2"
const DEFAULT_EXPIRY_HOURS = 24;
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I,O,0,1 to avoid confusion
const bytes = crypto.randomBytes(CODE_LENGTH);
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
code += chars[bytes[i] % chars.length];
}
// Format as XXXX-XXXX
return `${code.slice(0, 4)}-${code.slice(4)}`;
}
/**
* Generate a single-use invite code for agent registration.
*/
export async function createInviteCode(userId: string, expiryHours = DEFAULT_EXPIRY_HOURS) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + expiryHours);
// Retry up to 3 times in case of code collision (extremely unlikely)
for (let attempt = 0; attempt < 3; attempt++) {
const code = generateCode();
try {
return await prisma.agentInviteCode.create({
data: {
code,
createdById: userId,
expiresAt,
},
});
} catch (err: unknown) {
const prismaError = err as { code?: string };
if (prismaError.code === 'P2002' && attempt < 2) continue; // unique constraint, retry
throw err;
}
}
throw new AppError(500, 'Failed to generate unique invite code');
}
/**
* Validate an invite code. Returns the code record if valid.
* Throws if expired, already used, or not found.
*/
export async function validateInviteCode(code: string) {
const normalized = code.toUpperCase().trim();
const invite = await prisma.agentInviteCode.findUnique({
where: { code: normalized },
});
if (!invite) {
throw new AppError(404, 'Invalid invite code', 'INVALID_CODE');
}
if (invite.usedAt) {
throw new AppError(400, 'Invite code has already been used', 'CODE_USED');
}
if (invite.expiresAt < new Date()) {
throw new AppError(400, 'Invite code has expired', 'CODE_EXPIRED');
}
return invite;
}
/**
* Mark an invite code as used by an instance.
*/
export async function markCodeUsed(code: string, instanceId: string) {
const normalized = code.toUpperCase().trim();
await prisma.agentInviteCode.update({
where: { code: normalized },
data: {
usedAt: new Date(),
usedById: instanceId,
},
});
}
/**
* List all invite codes with optional filtering.
*/
export async function listInviteCodes(page = 1, limit = 50) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
prisma.agentInviteCode.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, name: true, email: true } },
},
}),
prisma.agentInviteCode.count(),
]);
return { data, total, page, limit };
}
/**
* Revoke (delete) an unused invite code.
*/
export async function revokeInviteCode(codeId: string) {
const invite = await prisma.agentInviteCode.findUnique({ where: { id: codeId } });
if (!invite) {
throw new AppError(404, 'Invite code not found');
}
if (invite.usedAt) {
throw new AppError(400, 'Cannot revoke a code that has already been used');
}
await prisma.agentInviteCode.delete({ where: { id: codeId } });
}

View File

@ -0,0 +1,130 @@
import fs from 'fs/promises';
import path from 'path';
import { promisify } from 'util';
import { parse as parseDotenv } from 'dotenv';
import * as docker from './docker.service';
import type { ExecutionDriver } from './execution-driver';
import { logger } from '../utils/logger';
/**
* LocalDriver wraps existing docker.service.ts functions and filesystem operations.
* This is a zero-behavior-change adapter all existing local instance operations
* pass through unchanged.
*/
export class LocalDriver implements ExecutionDriver {
// ─── Docker Compose Operations ──────────────────────────────
composeUp(projectDir: string, project: string, services?: string[]) {
return docker.composeUp(projectDir, project, services);
}
composeDown(projectDir: string, project: string, removeVolumes?: boolean) {
return docker.composeDown(projectDir, project, removeVolumes);
}
composeStop(projectDir: string, project: string) {
return docker.composeStop(projectDir, project);
}
composeRestart(projectDir: string, project: string, service?: string) {
return docker.composeRestart(projectDir, project, service);
}
composePull(projectDir: string, project: string) {
return docker.composePull(projectDir, project);
}
composeBuild(projectDir: string, project: string) {
return docker.composeBuild(projectDir, project);
}
composePs(projectDir: string, project: string) {
return docker.composePs(projectDir, project);
}
composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string) {
return docker.composeLogs(projectDir, project, service, tail, since);
}
composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>) {
return docker.composeExec(projectDir, project, service, command, timeoutMs, envVars);
}
// ─── Container Health ───────────────────────────────────────
waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number) {
return docker.waitForHealthy(containerName, timeoutMs, pollIntervalMs);
}
waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number) {
return docker.waitForHttp(url, timeoutMs, pollIntervalMs);
}
// ─── Filesystem Operations ──────────────────────────────────
async readEnvFile(basePath: string): Promise<Record<string, string> | null> {
try {
const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
return parseDotenv(Buffer.from(content));
} catch {
return null;
}
}
async writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) {
for (const file of files) {
const filePath = path.join(basePath, file.relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, file.content, 'utf-8');
logger.debug(`[local-driver] Wrote ${filePath}`);
}
}
async mkdir(basePath: string, relativePath: string) {
await fs.mkdir(path.join(basePath, relativePath), { recursive: true });
}
async fileExists(basePath: string, relativePath: string): Promise<boolean> {
try {
await fs.access(path.join(basePath, relativePath));
return true;
} catch {
return false;
}
}
async deleteDirectory(dirPath: string) {
await fs.rm(dirPath, { recursive: true, force: true });
}
async cloneSource(basePath: string, _gitRepo: string, _gitBranch: string, excludes?: string[]) {
// Local provisioning uses rsync from CML_SOURCE_PATH
const { CML_SOURCE_PATH } = await import('../config/env').then((m) => m.env);
if (!CML_SOURCE_PATH) {
throw new Error('CML_SOURCE_PATH not configured — cannot clone source');
}
// SECURITY: Validate exclude entries — reject anything with shell metacharacters
const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
const safeExcludes = (excludes || [
'node_modules', '.git', '.env', 'changemaker-control-panel', '.claude',
'api/dist', 'admin/dist', 'uploads', 'data',
]).filter((e) => SAFE_EXCLUDE.test(e));
// SECURITY: Use execFile with args array — no shell interpolation
const { execFile: execFileCb } = await import('child_process');
const execFileAsync = promisify(execFileCb);
const args = ['-a', ...safeExcludes.flatMap((e) => ['--exclude', e]), `${CML_SOURCE_PATH}/`, `${basePath}/`];
await execFileAsync('rsync', args, { timeout: 120_000 });
}
}
/** Singleton local driver instance. */
let _localDriver: LocalDriver | null = null;
export function getLocalDriver(): LocalDriver {
if (!_localDriver) {
_localDriver = new LocalDriver();
}
return _localDriver;
}

View File

@ -0,0 +1,264 @@
import https from 'https';
import { env } from '../config/env';
import type { ExecutionDriver } from './execution-driver';
import { AgentUnreachableError } from './execution-driver';
import type { ContainerInfo } from './docker.service';
import { logger } from '../utils/logger';
interface AgentRequestOptions {
method: 'GET' | 'POST' | 'DELETE';
path: string;
body?: unknown;
timeoutMs?: number;
}
/**
* RemoteDriver makes HTTPS calls to a remote CCP agent for all operations.
* Uses mTLS both CCP (client) and agent (server) present certificates.
*/
export class RemoteDriver implements ExecutionDriver {
constructor(
private agentUrl: string,
private slug: string,
private clientCert: Buffer,
private clientKey: Buffer,
private caCert: Buffer
) {}
// ─── HTTP Client ────────────────────────────────────────────
private async request<T = unknown>(opts: AgentRequestOptions): Promise<T> {
const url = new URL(opts.path, this.agentUrl);
const timeoutMs = opts.timeoutMs || env.AGENT_REQUEST_TIMEOUT_MS;
const payload = opts.body ? JSON.stringify(opts.body) : undefined;
return new Promise<T>((resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: url.port || 7443,
path: url.pathname + url.search,
method: opts.method,
headers: {
'Content-Type': 'application/json',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
cert: this.clientCert,
key: this.clientKey,
ca: this.caCert,
rejectUnauthorized: true,
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
try {
const err = JSON.parse(data);
reject(new Error(err.message || `Agent returned ${res.statusCode}`));
} catch {
reject(new Error(`Agent returned ${res.statusCode}: ${data.substring(0, 500)}`));
}
return;
}
try {
resolve(data ? JSON.parse(data) as T : (undefined as T));
} catch {
resolve(data as unknown as T);
}
});
}
);
req.on('error', (err) => {
reject(new AgentUnreachableError(this.agentUrl, err));
});
req.on('timeout', () => {
req.destroy();
reject(new AgentUnreachableError(this.agentUrl, new Error(`Timed out after ${timeoutMs}ms`)));
});
if (payload) req.write(payload);
req.end();
});
}
// ─── Docker Compose Operations ──────────────────────────────
async composeUp(_projectDir: string, _project: string, services?: string[]): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/up`,
body: { services },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composeDown(_projectDir: string, _project: string, removeVolumes?: boolean): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/down`,
body: { removeVolumes },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composeStop(_projectDir: string, _project: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/stop`,
});
}
async composeRestart(_projectDir: string, _project: string, service?: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/restart`,
body: { service },
});
}
async composePull(_projectDir: string, _project: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/pull`,
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composeBuild(_projectDir: string, _project: string): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/build`,
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async composePs(_projectDir: string, _project: string): Promise<ContainerInfo[]> {
return this.request<ContainerInfo[]>({
method: 'GET',
path: `/instance/${this.slug}/ps`,
});
}
async composeLogs(_projectDir: string, _project: string, service?: string, tail?: number, since?: string): Promise<string> {
const params = new URLSearchParams();
if (service) params.set('service', service);
if (tail) params.set('tail', String(tail));
if (since) params.set('since', since);
const qs = params.toString() ? `?${params}` : '';
return this.request<string>({
method: 'GET',
path: `/instance/${this.slug}/logs${qs}`,
});
}
async composeExec(_projectDir: string, _project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>): Promise<string> {
return this.request<string>({
method: 'POST',
path: `/instance/${this.slug}/exec`,
body: { service, command, envVars },
timeoutMs: timeoutMs || env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
// ─── Container Health ───────────────────────────────────────
async waitForHealthy(containerName: string, timeoutMs = 60_000, pollIntervalMs = 2_000): Promise<boolean> {
// For remote instances, poll the agent's ps endpoint
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const containers = await this.composePs('', '');
const container = containers.find((c) => c.name.includes(containerName) || c.service === containerName);
if (container?.health === 'healthy') return true;
if (container?.state === 'exited' || container?.state === 'dead') {
throw new Error(`Container ${containerName} exited unexpectedly`);
}
} catch (err) {
if (err instanceof AgentUnreachableError) throw err;
// Other errors — keep polling
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
}
async waitForHttp(url: string, timeoutMs = 120_000, pollIntervalMs = 3_000): Promise<boolean> {
// The URL is a local URL on the remote host. We ask the agent to check it.
// For now, poll the agent's health endpoint for the instance.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const containers = await this.composePs('', '');
const apiContainer = containers.find((c) => c.service === 'api');
if (apiContainer?.state === 'running' && apiContainer?.health === 'healthy') return true;
} catch (err) {
if (err instanceof AgentUnreachableError) throw err;
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`HTTP endpoint did not respond within ${timeoutMs}ms`);
}
// ─── Filesystem Operations ──────────────────────────────────
async readEnvFile(_basePath: string): Promise<Record<string, string> | null> {
try {
return await this.request<Record<string, string>>({
method: 'GET',
path: `/instance/${this.slug}/env`,
});
} catch {
return null;
}
}
async writeFiles(_basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void> {
await this.request({
method: 'POST',
path: `/instance/${this.slug}/files`,
body: { files },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
async mkdir(_basePath: string, relativePath: string): Promise<void> {
await this.request({
method: 'POST',
path: `/instance/${this.slug}/mkdir`,
body: { path: relativePath },
});
}
async fileExists(_basePath: string, relativePath: string): Promise<boolean> {
try {
await this.request({
method: 'GET',
path: `/instance/${this.slug}/env`, // reuse env endpoint as a proxy for file existence
});
return true;
} catch {
return false;
}
}
async deleteDirectory(_dirPath: string): Promise<void> {
// Remote directory deletion is handled by the agent during instance unregistration
logger.warn('[remote-driver] deleteDirectory called — remote cleanup handled by agent');
}
async cloneSource(_basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise<void> {
await this.request({
method: 'POST',
path: `/instance/${this.slug}/clone-source`,
body: { gitRepo, gitBranch, excludes },
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
});
}
}

View File

@ -124,6 +124,7 @@ export interface InstanceForTemplate {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
jvbAdvertiseIp: string | null;
pangolinEndpoint: string | null;
pangolinNewtId: string | null;
@ -293,3 +294,61 @@ export async function renderAllTemplates(context: TemplateContext, outputDir: st
export function clearTemplateCache(): void {
templateCache.clear();
}
/**
* Render all templates in memory and return them as an array of { relativePath, content }.
* Used for remote instances where we can't write to the local filesystem rendered
* files are sent to the remote agent via HTTP instead.
*/
export async function renderAllTemplatesInMemory(
context: TemplateContext
): Promise<Array<{ relativePath: string; content: string }>> {
clearTemplateCache();
const templatesDir = path.resolve(__dirname, '../..', 'templates');
const result: Array<{ relativePath: string; content: string }> = [];
const templateFiles = [
{ template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' },
{ template: 'env.hbs', output: '.env' },
{ template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' },
{ template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' },
{ template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' },
{ template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' },
{ template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' },
{ template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' },
];
for (const { template, output } of templateFiles) {
const templatePath = path.join(templatesDir, template);
try {
await fs.access(templatePath);
} catch {
logger.warn(`Template not found: ${template}, skipping`);
continue;
}
const rendered = await renderTemplate(template, context);
result.push({ relativePath: output, content: rendered });
}
// Read static files into memory
const staticFiles = [
'nginx/nginx.conf',
'configs/prometheus/alerts.yml',
'configs/alertmanager/alertmanager.yml',
'configs/grafana/dashboards/dashboards.yml',
'configs/grafana/dashboards/application-overview.json',
'configs/grafana/dashboards/api-performance.json',
'configs/grafana/dashboards/system-health.json',
];
for (const file of staticFiles) {
const srcPath = path.join(templatesDir, file);
try {
const content = await fs.readFile(srcPath, 'utf-8');
result.push({ relativePath: file, content });
} catch {
logger.warn(`Static file not found: ${file}, skipping`);
}
}
return result;
}

View File

@ -1099,6 +1099,35 @@ pangolin_connect_site() {
fi
}
configure_control_panel() {
header "Control Panel Registration"
if prompt_yes_no "Register this instance with a Changemaker Control Panel?"; then
echo ""
read -rp " Enter Control Panel URL (e.g., https://ccp.example.com): " ccp_url
read -rp " Enter invite code: " invite_code
read -rp " Agent URL (how the CCP reaches this host, e.g., https://this-host:7443): " agent_url
update_env_var "ENABLE_CCP_AGENT" "true"
update_env_var "CCP_URL" "$ccp_url"
update_env_var "CCP_INVITE_CODE" "$invite_code"
update_env_var "CCP_AGENT_URL" "$agent_url"
# Add ccp-agent to compose profiles
local existing_profiles
existing_profiles=$(grep -oP 'COMPOSE_PROFILES=\K.*' "$ENV_FILE" 2>/dev/null || echo "")
if [[ -n "$existing_profiles" ]]; then
update_env_var "COMPOSE_PROFILES" "${existing_profiles},ccp-agent"
else
update_env_var "COMPOSE_PROFILES" "ccp-agent"
fi
success "Control Panel registration configured — agent will phone home on startup"
else
update_env_var "ENABLE_CCP_AGENT" "false"
fi
}
configure_cors() {
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
# Include app subdomain + root domain (for MkDocs payment widgets) + localhost fallbacks
@ -1810,6 +1839,7 @@ main() {
configure_smtp
configure_features
configure_pangolin
configure_control_panel
configure_cors
generate_nginx_configs
generate_services_yaml

View File

@ -1323,6 +1323,34 @@ services:
profiles:
- monitoring
# =========================================================================
# CCP REMOTE AGENT (optional — enabled via COMPOSE_PROFILES=ccp-agent)
# =========================================================================
ccp-agent:
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/ccp-agent:${IMAGE_TAG:-latest}
container_name: ${COMPOSE_PROJECT_NAME:-changemaker-lite}-ccp-agent
restart: unless-stopped
profiles: ["ccp-agent"]
ports:
- "${CCP_AGENT_PORT:-7443}:7443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ccp-agent-data:/var/lib/ccp-agent
- ccp-agent-certs:/etc/ccp-agent
environment:
- AGENT_PORT=7443
- AGENT_DATA_DIR=/var/lib/ccp-agent
- CCP_URL=${CCP_URL:-}
- CCP_INVITE_CODE=${CCP_INVITE_CODE:-}
- CCP_AGENT_URL=${CCP_AGENT_URL:-}
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
- INSTANCE_BASE_PATH=/app/instance
logging: *default-logging
networks:
- changemaker-lite
# =============================================================================
# NETWORKS & VOLUMES
# =============================================================================
@ -1359,3 +1387,6 @@ volumes:
grafana-data:
alertmanager-data:
gotify-data:
# CCP Agent
ccp-agent-data:
ccp-agent-certs:

View File

@ -1348,6 +1348,37 @@ services:
profiles:
- monitoring
# =========================================================================
# CCP REMOTE AGENT (optional — enabled via COMPOSE_PROFILES=ccp-agent)
# =========================================================================
ccp-agent:
build:
context: ./changemaker-control-panel/agent
dockerfile: Dockerfile
container_name: ${COMPOSE_PROJECT_NAME:-changemaker-lite}-ccp-agent
restart: unless-stopped
profiles: ["ccp-agent"]
ports:
- "${CCP_AGENT_PORT:-7443}:7443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ccp-agent-data:/var/lib/ccp-agent
- ccp-agent-certs:/etc/ccp-agent
- .:/app/instance:ro
environment:
- AGENT_PORT=7443
- AGENT_DATA_DIR=/var/lib/ccp-agent
- CCP_URL=${CCP_URL:-}
- CCP_INVITE_CODE=${CCP_INVITE_CODE:-}
- CCP_AGENT_URL=${CCP_AGENT_URL:-}
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
- INSTANCE_BASE_PATH=/app/instance
logging: *default-logging
networks:
- changemaker-lite
# =============================================================================
# NETWORKS & VOLUMES
# =============================================================================
@ -1384,3 +1415,6 @@ volumes:
grafana-data:
alertmanager-data:
gotify-data:
# CCP Agent
ccp-agent-data:
ccp-agent-certs: