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:
parent
d17e197a1b
commit
38ccaa8a5b
20
.env.example
20
.env.example
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
138
changemaker-control-panel/admin/src/pages/InviteCodesPage.tsx
Normal file
138
changemaker-control-panel/admin/src/pages/InviteCodesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
17
changemaker-control-panel/agent/Dockerfile
Normal file
17
changemaker-control-panel/agent/Dockerfile
Normal 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"]
|
||||
1642
changemaker-control-panel/agent/package-lock.json
generated
Normal file
1642
changemaker-control-panel/agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
changemaker-control-panel/agent/package.json
Normal file
25
changemaker-control-panel/agent/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
changemaker-control-panel/agent/src/config/env.ts
Normal file
43
changemaker-control-panel/agent/src/config/env.ts
Normal 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();
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
71
changemaker-control-panel/agent/src/middleware/mtls-auth.ts
Normal file
71
changemaker-control-panel/agent/src/middleware/mtls-auth.ts
Normal 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();
|
||||
}
|
||||
105
changemaker-control-panel/agent/src/routes/backup.routes.ts
Normal file
105
changemaker-control-panel/agent/src/routes/backup.routes.ts
Normal 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;
|
||||
103
changemaker-control-panel/agent/src/routes/compose.routes.ts
Normal file
103
changemaker-control-panel/agent/src/routes/compose.routes.ts
Normal 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;
|
||||
51
changemaker-control-panel/agent/src/routes/files.routes.ts
Normal file
51
changemaker-control-panel/agent/src/routes/files.routes.ts
Normal 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;
|
||||
15
changemaker-control-panel/agent/src/routes/health.routes.ts
Normal file
15
changemaker-control-panel/agent/src/routes/health.routes.ts
Normal 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;
|
||||
@ -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;
|
||||
79
changemaker-control-panel/agent/src/routes/upgrade.routes.ts
Normal file
79
changemaker-control-panel/agent/src/routes/upgrade.routes.ts
Normal 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;
|
||||
159
changemaker-control-panel/agent/src/server.ts
Normal file
159
changemaker-control-panel/agent/src/server.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
134
changemaker-control-panel/agent/src/services/docker.service.ts
Normal file
134
changemaker-control-panel/agent/src/services/docker.service.ts
Normal 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;
|
||||
}
|
||||
104
changemaker-control-panel/agent/src/services/file.service.ts
Normal file
104
changemaker-control-panel/agent/src/services/file.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
12
changemaker-control-panel/agent/src/utils/logger.ts
Normal file
12
changemaker-control-panel/agent/src/utils/logger.ts
Normal 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()],
|
||||
});
|
||||
11
changemaker-control-panel/agent/src/utils/params.ts
Normal file
11
changemaker-control-panel/agent/src/utils/params.ts
Normal 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;
|
||||
}
|
||||
19
changemaker-control-panel/agent/tsconfig.json
Normal file
19
changemaker-control-panel/agent/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "instances" ADD COLUMN "enable_analytics" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -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;
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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({
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 } });
|
||||
}
|
||||
130
changemaker-control-panel/api/src/services/local-driver.ts
Normal file
130
changemaker-control-panel/api/src/services/local-driver.ts
Normal 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;
|
||||
}
|
||||
264
changemaker-control-panel/api/src/services/remote-driver.ts
Normal file
264
changemaker-control-panel/api/src/services/remote-driver.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
30
config.sh
30
config.sh
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user